summaryrefslogtreecommitdiffstats
path: root/comm/mail/test/browser
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/test/browser')
-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
477 files changed, 85161 insertions, 0 deletions
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
+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
+---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
+
+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
+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=
+ 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
+ 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-----
+
+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
+=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-----
+
+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
+=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-----
+
+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
+=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-----
+
+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
+=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
+
+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
+--------------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-----
+
+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
+=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
+
+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
+--------------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
+
+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
+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
+
+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
+--------------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
+
+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
+--------------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=
+ 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
+ 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-----
+
+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
+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=
+ 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
+ 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-----
+
+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
+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=
+ 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
+ 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-----
+
+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
+=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-----
+
+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
+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=
+ 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
+ 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=
+ 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
+ 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-----
+
+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
+/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=
+ 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
+ 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-----
+
+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
+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=
+ 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
+ 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=
+ 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
+ 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-----
+
+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
+=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
+
+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
+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-----
+
+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
+=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-----
+
+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
+=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-----
+
+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
+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-----
+
+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
+=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-----
+
+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
+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-----
+
+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
+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-----
+
+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
+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>