/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); XPCOMUtils.defineLazyGlobalGetters(this, ["IOUtils", "PathUtils"]); ChromeUtils.defineModuleGetter( this, "MailServices", "resource:///modules/MailServices.jsm" ); var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); let { MsgUtils } = ChromeUtils.import( "resource:///modules/MimeMessageUtils.jsm" ); let parserUtils = Cc["@mozilla.org/parserutils;1"].getService( Ci.nsIParserUtils ); // eslint-disable-next-line mozilla/reject-importGlobalProperties Cu.importGlobalProperties(["File"]); const deliveryFormats = [ { id: Ci.nsIMsgCompSendFormat.Auto, value: "auto" }, { id: Ci.nsIMsgCompSendFormat.PlainText, value: "plaintext" }, { id: Ci.nsIMsgCompSendFormat.HTML, value: "html" }, { id: Ci.nsIMsgCompSendFormat.Both, value: "both" }, ]; async function parseComposeRecipientList( list, requireSingleValidEmail = false ) { if (!list) { return list; } function isValidAddress(address) { return address.includes("@", 1) && !address.endsWith("@"); } // A ComposeRecipientList could be just a single ComposeRecipient. if (!Array.isArray(list)) { list = [list]; } let recipients = []; for (let recipient of list) { if (typeof recipient == "string") { let addressObjects = MailServices.headerParser.makeFromDisplayAddress(recipient); for (let ao of addressObjects) { if (requireSingleValidEmail && !isValidAddress(ao.email)) { throw new ExtensionError(`Invalid address: ${ao.email}`); } recipients.push( MailServices.headerParser.makeMimeAddress(ao.name, ao.email) ); } continue; } if (!("addressBookCache" in this)) { await extensions.asyncLoadModule("addressBook"); } if (recipient.type == "contact") { let contactNode = this.addressBookCache.findContactById(recipient.id); if ( requireSingleValidEmail && !isValidAddress(contactNode.item.primaryEmail) ) { throw new ExtensionError( `Contact does not have a valid email address: ${recipient.id}` ); } recipients.push( MailServices.headerParser.makeMimeAddress( contactNode.item.displayName, contactNode.item.primaryEmail ) ); } else { if (requireSingleValidEmail) { throw new ExtensionError("Mailing list not allowed."); } let mailingListNode = this.addressBookCache.findMailingListById( recipient.id ); recipients.push( MailServices.headerParser.makeMimeAddress( mailingListNode.item.dirName, mailingListNode.item.description || mailingListNode.item.dirName ) ); } } if (requireSingleValidEmail && recipients.length != 1) { throw new ExtensionError( `Exactly one address instead of ${recipients.length} is required.` ); } return recipients.join(","); } function composeWindowIsReady(composeWindow) { return new Promise(resolve => { if (composeWindow.composeEditorReady) { resolve(); return; } composeWindow.addEventListener("compose-editor-ready", resolve, { once: true, }); }); } async function openComposeWindow(relatedMessageId, type, details, extension) { let format = Ci.nsIMsgCompFormat.Default; let identity = null; if (details) { if (details.isPlainText != null) { format = details.isPlainText ? Ci.nsIMsgCompFormat.PlainText : Ci.nsIMsgCompFormat.HTML; } else { // If none or both of details.body and details.plainTextBody are given, the // default compose format will be used. if (details.body != null && details.plainTextBody == null) { format = Ci.nsIMsgCompFormat.HTML; } if (details.plainTextBody != null && details.body == null) { format = Ci.nsIMsgCompFormat.PlainText; } } if (details.identityId != null) { if (!extension.hasPermission("accountsRead")) { throw new ExtensionError( 'Using identities requires the "accountsRead" permission' ); } identity = MailServices.accounts.allIdentities.find( i => i.key == details.identityId ); if (!identity) { throw new ExtensionError(`Identity not found: ${details.identityId}`); } } } // ForwardInline is totally broken, see bug 1513824. Fake it 'til we make it. if ( [ Ci.nsIMsgCompType.ForwardInline, Ci.nsIMsgCompType.Redirect, Ci.nsIMsgCompType.EditAsNew, Ci.nsIMsgCompType.Template, ].includes(type) ) { let msgHdr = null; let msgURI = null; if (relatedMessageId) { msgHdr = messageTracker.getMessage(relatedMessageId); msgURI = msgHdr.folder.getUriForMsg(msgHdr); } // For the types in this code path, OpenComposeWindow only uses // nsIMsgCompFormat.Default or OppositeOfDefault. Check which is needed. // See https://hg.mozilla.org/comm-central/file/592fb5c396ebbb75d4acd1f1287a26f56f4164b3/mailnews/compose/src/nsMsgComposeService.cpp#l395 if (format != Ci.nsIMsgCompFormat.Default) { // The mimeConverter used in this code path is not setting any format but // defaults to plaintext if no identity and also no default account is set. // The "mail.identity.default.compose_html" preference is NOT used. let usedIdentity = identity || MailServices.accounts.defaultAccount?.defaultIdentity; let defaultFormat = usedIdentity?.composeHtml ? Ci.nsIMsgCompFormat.HTML : Ci.nsIMsgCompFormat.PlainText; format = format == defaultFormat ? Ci.nsIMsgCompFormat.Default : Ci.nsIMsgCompFormat.OppositeOfDefault; } let composeWindowPromise = new Promise(resolve => { function listener(event) { let composeWindow = event.target.ownerGlobal; // Skip if this window has been processed already. This already helps // a lot to assign the opened windows in the correct order to the // OpenCompomposeWindow calls. if (composeWindowTracker.has(composeWindow)) { return; } // Do a few more checks to make sure we are looking at the expected // window. This is still a hack. We need to make OpenCompomposeWindow // actually return the opened window. let _msgURI = composeWindow.gMsgCompose.originalMsgURI; let _type = composeWindow.gComposeType; if (_msgURI == msgURI && _type == type) { composeWindowTracker.add(composeWindow); windowTracker.removeListener("compose-editor-ready", listener); resolve(composeWindow); } } windowTracker.addListener("compose-editor-ready", listener); }); MailServices.compose.OpenComposeWindow( null, msgHdr, msgURI, type, format, identity, null, null ); let composeWindow = await composeWindowPromise; if (details) { await setComposeDetails(composeWindow, details, extension); if (details.attachments != null) { let attachmentData = []; for (let data of details.attachments) { attachmentData.push(await createAttachment(data)); } await AddAttachmentsToWindow(composeWindow, attachmentData); } } composeWindow.gContentChanged = false; return composeWindow; } let params = Cc[ "@mozilla.org/messengercompose/composeparams;1" ].createInstance(Ci.nsIMsgComposeParams); let composeFields = Cc[ "@mozilla.org/messengercompose/composefields;1" ].createInstance(Ci.nsIMsgCompFields); if (relatedMessageId) { let msgHdr = messageTracker.getMessage(relatedMessageId); params.originalMsgURI = msgHdr.folder.getUriForMsg(msgHdr); } params.type = type; params.format = format; if (identity) { params.identity = identity; } params.composeFields = composeFields; let composeWindow = Services.ww.openWindow( null, "chrome://messenger/content/messengercompose/messengercompose.xhtml", "_blank", "all,chrome,dialog=no,status,toolbar", params ); await composeWindowIsReady(composeWindow); // Not all details can be set with params for all types, so some need an extra // call to setComposeDetails here. Since we have to use setComposeDetails for // the EditAsNew code path, unify API behavior by always calling it here too. if (details) { await setComposeDetails(composeWindow, details, extension); if (details.attachments != null) { let attachmentData = []; for (let data of details.attachments) { attachmentData.push(await createAttachment(data)); } await AddAttachmentsToWindow(composeWindow, attachmentData); } } composeWindow.gContentChanged = false; return composeWindow; } /** * Converts "\r\n" line breaks to "\n" and removes trailing line breaks. * * @param {string} content - original content * @returns {string} - trimmed content */ function trimContent(content) { let data = content.replaceAll("\r\n", "\n").split("\n"); while (data[data.length - 1] == "") { data.pop(); } return data.join("\n"); } /** * Get the compose details of the requested compose window. * * @param {DOMWindow} composeWindow * @param {ExtensionData} extension * @returns {ComposeDetails} * * @see mail/components/extensions/schemas/compose.json */ async function getComposeDetails(composeWindow, extension) { let composeFields = composeWindow.GetComposeDetails(); let editor = composeWindow.GetCurrentEditor(); let type; // check all known nsIMsgComposeParams switch (composeWindow.gComposeType) { case Ci.nsIMsgCompType.Draft: type = "draft"; break; case Ci.nsIMsgCompType.New: case Ci.nsIMsgCompType.Template: case Ci.nsIMsgCompType.MailToUrl: case Ci.nsIMsgCompType.EditAsNew: case Ci.nsIMsgCompType.EditTemplate: case Ci.nsIMsgCompType.NewsPost: type = "new"; break; case Ci.nsIMsgCompType.Reply: case Ci.nsIMsgCompType.ReplyAll: case Ci.nsIMsgCompType.ReplyToSender: case Ci.nsIMsgCompType.ReplyToGroup: case Ci.nsIMsgCompType.ReplyToSenderAndGroup: case Ci.nsIMsgCompType.ReplyWithTemplate: case Ci.nsIMsgCompType.ReplyToList: type = "reply"; break; case Ci.nsIMsgCompType.ForwardAsAttachment: case Ci.nsIMsgCompType.ForwardInline: type = "forward"; break; case Ci.nsIMsgCompType.Redirect: type = "redirect"; break; } let relatedMessageId = null; if (composeWindow.gMsgCompose.originalMsgURI) { try { // This throws for messages opened from file and then being replied to. let relatedMsgHdr = composeWindow.gMessenger.msgHdrFromURI( composeWindow.gMsgCompose.originalMsgURI ); relatedMessageId = messageTracker.getId(relatedMsgHdr); } catch (ex) { // We are currently unable to get the fake msgHdr from the uri of messages // opened from file. } } let customHeaders = [...composeFields.headerNames] .map(h => h.toLowerCase()) .filter(h => h.startsWith("x-")) .map(h => { return { // All-lower-case-names are ugly, so capitalize first letters. name: h.replace(/(^|-)[a-z]/g, function (match) { return match.toUpperCase(); }), value: composeFields.getHeader(h), }; }); // We have two file carbon copy settings: fcc and fcc2. fcc allows to override // the default identity fcc and fcc2 is coupled to the UI selection. let overrideDefaultFcc = false; if (composeFields.fcc && composeFields.fcc != "") { overrideDefaultFcc = true; } let overrideDefaultFccFolder = ""; if (overrideDefaultFcc && !composeFields.fcc.startsWith("nocopy://")) { let folder = MailUtils.getExistingFolder(composeFields.fcc); if (folder) { overrideDefaultFccFolder = convertFolder(folder); } } let additionalFccFolder = ""; if (composeFields.fcc2 && !composeFields.fcc2.startsWith("nocopy://")) { let folder = MailUtils.getExistingFolder(composeFields.fcc2); if (folder) { additionalFccFolder = convertFolder(folder); } } let deliveryFormat = composeWindow.IsHTMLEditor() ? deliveryFormats.find(f => f.id == composeFields.deliveryFormat).value : null; let body = trimContent( editor.outputToString("text/html", Ci.nsIDocumentEncoder.OutputRaw) ); let plainTextBody; if (composeWindow.IsHTMLEditor()) { plainTextBody = trimContent(MsgUtils.convertToPlainText(body, true)); } else { plainTextBody = parserUtils.convertToPlainText( body, Ci.nsIDocumentEncoder.OutputLFLineBreak, 0 ); // Remove the extra new line at the end. if (plainTextBody.endsWith("\n")) { plainTextBody = plainTextBody.slice(0, -1); } } let details = { from: composeFields.splitRecipients(composeFields.from, false).shift(), to: composeFields.splitRecipients(composeFields.to, false), cc: composeFields.splitRecipients(composeFields.cc, false), bcc: composeFields.splitRecipients(composeFields.bcc, false), overrideDefaultFcc, overrideDefaultFccFolder: overrideDefaultFcc ? overrideDefaultFccFolder : null, additionalFccFolder, type, relatedMessageId, replyTo: composeFields.splitRecipients(composeFields.replyTo, false), followupTo: composeFields.splitRecipients(composeFields.followupTo, false), newsgroups: composeFields.newsgroups ? composeFields.newsgroups.split(",") : [], subject: composeFields.subject, isPlainText: !composeWindow.IsHTMLEditor(), deliveryFormat, body, plainTextBody, customHeaders, priority: composeFields.priority.toLowerCase() || "normal", returnReceipt: composeFields.returnReceipt, deliveryStatusNotification: composeFields.DSN, attachVCard: composeFields.attachVCard, }; if (extension.hasPermission("accountsRead")) { details.identityId = composeWindow.getCurrentIdentityKey(); } return details; } async function setFromField(composeWindow, details, extension) { if (!details || details.from == null) { return; } let from; // Re-throw exceptions from parseComposeRecipientList with a prefix to // minimize developers debugging time and make clear where restrictions are // coming from. try { from = await parseComposeRecipientList(details.from, true); } catch (ex) { throw new ExtensionError(`ComposeDetails.from: ${ex.message}`); } if (!from) { throw new ExtensionError( "ComposeDetails.from: Address must not be set to an empty string." ); } let identityList = composeWindow.document.getElementById("msgIdentity"); // Make the from field editable only, if from differs from the currently shown identity. if (from != identityList.value) { let activeElement = composeWindow.document.activeElement; // Manually update from, using the same approach used in // https://hg.mozilla.org/comm-central/file/1283451c02926e2b7506a6450445b81f6d076f89/mail/components/compose/content/MsgComposeCommands.js#l3621 composeWindow.MakeFromFieldEditable(true); identityList.value = from; activeElement.focus(); } } /** * Updates the compose details of the specified compose window, overwriting any * property given in the details object. * * @param {DOMWindow} composeWindow * @param {ComposeDetails} details - compose details to update the composer with * @param {ExtensionData} extension * * @see mail/components/extensions/schemas/compose.json */ async function setComposeDetails(composeWindow, details, extension) { let activeElement = composeWindow.document.activeElement; // Check if conflicting formats have been specified. if ( details.isPlainText === true && details.body != null && details.plainTextBody == null ) { throw new ExtensionError( "Conflicting format setting: isPlainText = true and providing a body but no plainTextBody." ); } if ( details.isPlainText === false && details.body == null && details.plainTextBody != null ) { throw new ExtensionError( "Conflicting format setting: isPlainText = false and providing a plainTextBody but no body." ); } // Remove any unsupported body type. Otherwise, this will throw an // NS_UNEXPECTED_ERROR later. Note: setComposeDetails cannot change the compose // format, details.isPlainText is ignored. if (composeWindow.IsHTMLEditor()) { delete details.plainTextBody; } else { delete details.body; } if (details.identityId) { if (!extension.hasPermission("accountsRead")) { throw new ExtensionError( 'Using identities requires the "accountsRead" permission' ); } let identity = MailServices.accounts.allIdentities.find( i => i.key == details.identityId ); if (!identity) { throw new ExtensionError(`Identity not found: ${details.identityId}`); } let identityElement = composeWindow.document.getElementById("msgIdentity"); identityElement.selectedItem = [ ...identityElement.childNodes[0].childNodes, ].find(e => e.getAttribute("identitykey") == details.identityId); composeWindow.LoadIdentity(false); } for (let field of ["to", "cc", "bcc", "replyTo", "followupTo"]) { if (field in details) { details[field] = await parseComposeRecipientList(details[field]); } } if (Array.isArray(details.newsgroups)) { details.newsgroups = details.newsgroups.join(","); } composeWindow.SetComposeDetails(details); await setFromField(composeWindow, details, extension); // Set file carbon copy values. if (details.overrideDefaultFcc === false) { composeWindow.gMsgCompose.compFields.fcc = ""; } else if (details.overrideDefaultFccFolder != null) { // Override identity fcc with enforced value. if (details.overrideDefaultFccFolder) { let uri = folderPathToURI( details.overrideDefaultFccFolder.accountId, details.overrideDefaultFccFolder.path ); let folder = MailUtils.getExistingFolder(uri); if (folder) { composeWindow.gMsgCompose.compFields.fcc = uri; } else { throw new ExtensionError( `Invalid MailFolder: {accountId:${details.overrideDefaultFccFolder.accountId}, path:${details.overrideDefaultFccFolder.path}}` ); } } else { composeWindow.gMsgCompose.compFields.fcc = "nocopy://"; } } else if ( details.overrideDefaultFcc === true && composeWindow.gMsgCompose.compFields.fcc == "" ) { throw new ExtensionError( `Setting overrideDefaultFcc to true requires setting overrideDefaultFccFolder as well` ); } if (details.additionalFccFolder != null) { if (details.additionalFccFolder) { let uri = folderPathToURI( details.additionalFccFolder.accountId, details.additionalFccFolder.path ); let folder = MailUtils.getExistingFolder(uri); if (folder) { composeWindow.gMsgCompose.compFields.fcc2 = uri; } else { throw new ExtensionError( `Invalid MailFolder: {accountId:${details.additionalFccFolder.accountId}, path:${details.additionalFccFolder.path}}` ); } } else { composeWindow.gMsgCompose.compFields.fcc2 = ""; } } // Update custom headers, if specified. if (details.customHeaders) { let newHeaderNames = details.customHeaders.map(h => h.name.toUpperCase()); let obsoleteHeaderNames = [ ...composeWindow.gMsgCompose.compFields.headerNames, ] .map(h => h.toUpperCase()) .filter(h => h.startsWith("X-") && !newHeaderNames.hasOwnProperty(h)); for (let headerName of obsoleteHeaderNames) { composeWindow.gMsgCompose.compFields.deleteHeader(headerName); } for (let { name, value } of details.customHeaders) { composeWindow.gMsgCompose.compFields.setHeader(name, value); } } // Update priorities. The enum in the schema defines all allowed values, no // need to validate here. if (details.priority) { if (details.priority == "normal") { composeWindow.gMsgCompose.compFields.priority = ""; } else { composeWindow.gMsgCompose.compFields.priority = details.priority[0].toUpperCase() + details.priority.slice(1); } composeWindow.updatePriorityToolbarButton( composeWindow.gMsgCompose.compFields.priority ); } // Update receipt notifications. if (details.returnReceipt != null) { composeWindow.ToggleReturnReceipt(details.returnReceipt); } if ( details.deliveryStatusNotification != null && details.deliveryStatusNotification != composeWindow.gMsgCompose.compFields.DSN ) { let target = composeWindow.document.getElementById("dsnMenu"); composeWindow.ToggleDSN(target); } if (details.deliveryFormat && composeWindow.IsHTMLEditor()) { // Do not throw when a deliveryFormat is set on a plaint text composer, because // it is allowed to set ComposeDetails of an html composer onto a plain text // composer (and automatically pick the plainText body). The deliveryFormat // will be ignored. composeWindow.gMsgCompose.compFields.deliveryFormat = deliveryFormats.find( f => f.value == details.deliveryFormat ).id; composeWindow.initSendFormatMenu(); } if (details.attachVCard != null) { composeWindow.gMsgCompose.compFields.attachVCard = details.attachVCard; composeWindow.gAttachVCardOptionChanged = true; } activeElement.focus(); } async function fileURLForFile(file) { let realFile = await getRealFileForFile(file); return Services.io.newFileURI(realFile).spec; } async function createAttachment(data) { let attachment = Cc[ "@mozilla.org/messengercompose/attachment;1" ].createInstance(Ci.nsIMsgAttachment); if (data.id) { if (!composeAttachmentTracker.hasAttachment(data.id)) { throw new ExtensionError(`Invalid attachment ID: ${data.id}`); } let { attachment: originalAttachment, window: originalWindow } = composeAttachmentTracker.getAttachment(data.id); let originalAttachmentItem = originalWindow.gAttachmentBucket.findItemForAttachment( originalAttachment ); attachment.name = data.name || originalAttachment.name; attachment.size = originalAttachment.size; attachment.url = originalAttachment.url; return { attachment, originalAttachment, originalCloudFileAccount: originalAttachmentItem.cloudFileAccount, originalCloudFileUpload: originalAttachmentItem.cloudFileUpload, }; } if (data.file) { attachment.name = data.name || data.file.name; attachment.size = data.file.size; attachment.url = await fileURLForFile(data.file); attachment.contentType = data.file.type; return { attachment }; } throw new ExtensionError(`Failed to create attachment.`); } async function AddAttachmentsToWindow(window, attachmentData) { await window.AddAttachments(attachmentData.map(a => a.attachment)); // Check if an attachment has been cloned and the cloudFileUpload needs to be // re-applied. for (let entry of attachmentData) { let addedAttachmentItem = window.gAttachmentBucket.findItemForAttachment( entry.attachment ); if (!addedAttachmentItem) { continue; } if ( !entry.originalAttachment || !entry.originalCloudFileAccount || !entry.originalCloudFileUpload ) { continue; } let updateSettings = { cloudFileAccount: entry.originalCloudFileAccount, relatedCloudFileUpload: entry.originalCloudFileUpload, }; if (entry.originalAttachment.name != entry.attachment.name) { updateSettings.name = entry.attachment.name; } try { await window.UpdateAttachment(addedAttachmentItem, updateSettings); } catch (ex) { throw new ExtensionError(ex.message); } } } var composeStates = { _states: { canSendNow: "cmd_sendNow", canSendLater: "cmd_sendLater", }, getStates(tab) { let states = {}; for (let [state, command] of Object.entries(this._states)) { state[state] = tab.nativeTab.defaultController.isCommandEnabled(command); } return states; }, // Translate core states (commands) to API states. convert(states) { let converted = {}; for (let [state, command] of Object.entries(this._states)) { if (states.hasOwnProperty(command)) { converted[state] = states[command]; } } return converted; }, }; class MsgOperationObserver { constructor(composeWindow) { this.composeWindow = composeWindow; this.savedMessages = []; this.headerMessageId = null; this.deliveryCallbacks = null; this.preparedCallbacks = null; this.classifiedMessages = new Map(); // The preparedPromise fulfills when the message has been prepared and handed // over to the send process. this.preparedPromise = new Promise((resolve, reject) => { this.preparedCallbacks = { resolve, reject }; }); // The deliveryPromise fulfills when the message has been saved/send. this.deliveryPromise = new Promise((resolve, reject) => { this.deliveryCallbacks = { resolve, reject }; }); Services.obs.addObserver(this, "mail:composeSendProgressStop"); this.composeWindow.gMsgCompose.addMsgSendListener(this); MailServices.mfn.addListener(this, MailServices.mfn.msgsClassified); this.composeWindow.addEventListener( "compose-prepare-message-success", event => this.preparedCallbacks.resolve(), { once: true } ); this.composeWindow.addEventListener( "compose-prepare-message-failure", event => this.preparedCallbacks.reject(event.detail.exception), { once: true } ); } // Observer for mail:composeSendProgressStop. observe(subject, topic, data) { let { composeWindow } = subject.wrappedJSObject; if (composeWindow == this.composeWindow) { this.deliveryCallbacks.resolve(); } } // nsIMsgSendListener onStartSending(msgID, msgSize) {} onProgress(msgID, progress, progressMax) {} onStatus(msgID, msg) {} onStopSending(msgID, status, msg, returnFile) { if (!Components.isSuccessCode(status)) { this.deliveryCallbacks.reject( new ExtensionError("Message operation failed") ); return; } // In case of success, this is only called for sendNow, stating the // headerMessageId of the outgoing message. // The msgID starts with < and ends with > which is not used by the API. this.headerMessageId = msgID.replace(/^<|>$/g, ""); } onGetDraftFolderURI(msgID, folderURI) { // Only called for save operations and sendLater. Collect messageIds and // folders of saved messages. let headerMessageId = msgID.replace(/^<|>$/g, ""); this.savedMessages.push(JSON.stringify({ headerMessageId, folderURI })); } onSendNotPerformed(msgID, status) {} onTransportSecurityError(msgID, status, secInfo, location) {} // Implementation for nsIMsgFolderListener::msgsClassified msgsClassified(msgs, junkProcessed, traitProcessed) { // Collect all msgHdrs added to folders during the current message operation. for (let msgHdr of msgs) { let key = JSON.stringify({ headerMessageId: msgHdr.messageId, folderURI: msgHdr.folder.URI, }); if (!this.classifiedMessages.has(key)) { this.classifiedMessages.set(key, convertMessage(msgHdr)); } } } /** * @typedef MsgOperationInfo * @property {string} headerMessageId - the id used in the "Message-Id" header * of the outgoing message, only available for the "sendNow" mode * @property {MessageHeader[]} messages - array of WebExtension MessageHeader * objects, with information about saved messages (depends on fcc config) * @see mail/components/extensions/schemas/compose.json */ /** * Returns a Promise, which resolves once the message operation has finished. * * @returns {Promise} - Promise for information about the * performed message operation. */ async waitForOperation() { try { await Promise.all([this.deliveryPromise, this.preparedPromise]); return { messages: this.savedMessages .map(m => this.classifiedMessages.get(m)) .filter(Boolean), headerMessageId: this.headerMessageId, }; } catch (ex) { // In case of error, reject the pending delivery Promise. this.deliveryCallbacks.reject(); throw ex; } finally { MailServices.mfn.removeListener(this); Services.obs.removeObserver(this, "mail:composeSendProgressStop"); this.composeWindow?.gMsgCompose?.removeMsgSendListener(this); } } } /** * @typedef MsgOperationReturnValue * @property {string} headerMessageId - the id used in the "Message-Id" header * of the outgoing message, only available for the "sendNow" mode * @property {MessageHeader[]} messages - array of WebExtension MessageHeader * objects, with information about saved messages (depends on fcc config) * @see mail/components/extensions/schemas/compose.json * @property {string} mode - the mode of the message operation * @see mail/components/extensions/schemas/compose.json */ /** * Executes the given save/send command. The returned Promise resolves once the * message operation has finished. * * @returns {Promise} - Promise for information about * the performed message operation, which is passed to the WebExtension. */ async function goDoCommand(composeWindow, extension, mode) { let commands = new Map([ ["draft", "cmd_saveAsDraft"], ["template", "cmd_saveAsTemplate"], ["sendNow", "cmd_sendNow"], ["sendLater", "cmd_sendLater"], ]); if (!commands.has(mode)) { throw new ExtensionError(`Unsupported mode: ${mode}`); } if (!composeWindow.defaultController.isCommandEnabled(commands.get(mode))) { throw new ExtensionError( `Message compose window not ready for the requested command` ); } let sendPromise = new Promise((resolve, reject) => { let listener = { onSuccess(window, mode, messages, headerMessageId) { if (window == composeWindow) { afterSaveSendEventTracker.removeListener(listener); let info = { mode, messages }; if (mode == "sendNow") { info.headerMessageId = headerMessageId; } resolve(info); } }, onFailure(window, mode, exception) { if (window == composeWindow) { afterSaveSendEventTracker.removeListener(listener); reject(exception); } }, modes: [mode], extension, }; afterSaveSendEventTracker.addListener(listener); }); // Initiate send. switch (mode) { case "draft": composeWindow.SaveAsDraft(); break; case "template": composeWindow.SaveAsTemplate(); break; case "sendNow": composeWindow.SendMessage(); break; case "sendLater": composeWindow.SendMessageLater(); break; } return sendPromise; } var afterSaveSendEventTracker = { listeners: new Set(), addListener(listener) { this.listeners.add(listener); }, removeListener(listener) { this.listeners.delete(listener); }, async handleSuccess(window, mode, messages, headerMessageId) { for (let listener of this.listeners) { if (!listener.modes.includes(mode)) { continue; } await listener.onSuccess( window, mode, messages.map(message => { // Strip data from MessageHeader if this extension doesn't have // the required permission. let clone = Object.assign({}, message); if (!listener.extension.hasPermission("accountsRead")) { delete clone.folders; } return clone; }), headerMessageId ); } }, async handleFailure(window, mode, exception) { for (let listener of this.listeners) { if (!listener.modes.includes(mode)) { continue; } await listener.onFailure(window, mode, exception); } }, // Event handler for the "compose-prepare-message-start", which initiates a // new message operation (send or save). handleEvent(event) { let composeWindow = event.target; let msgType = event.detail.msgType; let modes = new Map([ [Ci.nsIMsgCompDeliverMode.SaveAsDraft, "draft"], [Ci.nsIMsgCompDeliverMode.SaveAsTemplate, "template"], [Ci.nsIMsgCompDeliverMode.Now, "sendNow"], [Ci.nsIMsgCompDeliverMode.Later, "sendLater"], ]); let mode = modes.get(msgType); if (mode && this.listeners.size > 0) { let msgOperationObserver = new MsgOperationObserver(composeWindow); msgOperationObserver .waitForOperation() .then(msgOperationInfo => this.handleSuccess( composeWindow, mode, msgOperationInfo.messages, msgOperationInfo.headerMessageId ) ) .catch(msgOperationException => this.handleFailure(composeWindow, mode, msgOperationException) ); } }, }; windowTracker.addListener( "compose-prepare-message-start", afterSaveSendEventTracker ); var beforeSendEventTracker = { listeners: new Set(), addListener(listener) { this.listeners.add(listener); if (this.listeners.size == 1) { windowTracker.addListener("beforesend", this); } }, removeListener(listener) { this.listeners.delete(listener); if (this.listeners.size == 0) { windowTracker.removeListener("beforesend", this); } }, async handleEvent(event) { event.preventDefault(); let sendPromise = event.detail; let composeWindow = event.target; await composeWindowIsReady(composeWindow); composeWindow.ToggleWindowLock(true); // Send process waits till sendPromise.resolve() or sendPromise.reject() is // called. for (let { handler, extension } of this.listeners) { let result = await handler( composeWindow, await getComposeDetails(composeWindow, extension) ); if (!result) { continue; } if (result.cancel) { composeWindow.ToggleWindowLock(false); sendPromise.reject(); return; } if (result.details) { await setComposeDetails(composeWindow, result.details, extension); } } // Load the new details into gMsgCompose.compFields for sending. composeWindow.GetComposeDetails(); composeWindow.ToggleWindowLock(false); sendPromise.resolve(); }, }; var composeAttachmentTracker = { _nextId: 1, _attachments: new Map(), _attachmentIds: new Map(), getId(attachment, window) { if (this._attachmentIds.has(attachment)) { return this._attachmentIds.get(attachment).id; } let id = this._nextId++; this._attachments.set(id, { attachment, window }); this._attachmentIds.set(attachment, { id, window }); return id; }, getAttachment(id) { return this._attachments.get(id); }, hasAttachment(id) { return this._attachments.has(id); }, forgetAttachment(attachment) { // This is called on all attachments when the window closes, whether the // attachments have been assigned IDs or not. let id = this._attachmentIds.get(attachment)?.id; if (id) { this._attachmentIds.delete(attachment); this._attachments.delete(id); } }, forgetAttachments(window) { if (window.location.href == COMPOSE_WINDOW_URI) { let bucket = window.document.getElementById("attachmentBucket"); for (let item of bucket.itemChildren) { this.forgetAttachment(item.attachment); } } }, convert(attachment, window) { return { id: this.getId(attachment, window), name: attachment.name, size: attachment.size, }; }, getFile(attachment) { if (!attachment) { return null; } let uri = Services.io.newURI(attachment.url).QueryInterface(Ci.nsIFileURL); // Enforce the actual filename used in the composer, do not leak internal or // temporary filenames. return File.createFromNsIFile(uri.file, { name: attachment.name }); }, }; windowTracker.addCloseListener( composeAttachmentTracker.forgetAttachments.bind(composeAttachmentTracker) ); var composeWindowTracker = new Set(); windowTracker.addCloseListener(window => composeWindowTracker.delete(window)); this.compose = class extends ExtensionAPIPersistent { PERSISTENT_EVENTS = { // For primed persistent events (deactivated background), the context is only // available after fire.wakeup() has fulfilled (ensuring the convert() function // has been called). onBeforeSend({ context, fire }) { const { extension } = this; const { tabManager, windowManager } = extension; let listener = { async handler(window, details) { if (fire.wakeup) { await fire.wakeup(); } let win = windowManager.wrapWindow(window); return fire.async( tabManager.convert(win.activeTab.nativeTab), details ); }, extension, }; beforeSendEventTracker.addListener(listener); return { unregister: () => { beforeSendEventTracker.removeListener(listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, onAfterSend({ context, fire }) { const { extension } = this; const { tabManager, windowManager } = extension; let listener = { async onSuccess(window, mode, messages, headerMessageId) { let win = windowManager.wrapWindow(window); let tab = tabManager.convert(win.activeTab.nativeTab); if (fire.wakeup) { await fire.wakeup(); } let sendInfo = { mode, messages }; if (mode == "sendNow") { sendInfo.headerMessageId = headerMessageId; } return fire.async(tab, sendInfo); }, async onFailure(window, mode, exception) { let win = windowManager.wrapWindow(window); let tab = tabManager.convert(win.activeTab.nativeTab); if (fire.wakeup) { await fire.wakeup(); } return fire.async(tab, { mode, messages: [], error: exception.message, }); }, modes: ["sendNow", "sendLater"], extension, }; afterSaveSendEventTracker.addListener(listener); return { unregister: () => { afterSaveSendEventTracker.removeListener(listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, onAfterSave({ context, fire }) { const { extension } = this; const { tabManager, windowManager } = extension; let listener = { async onSuccess(window, mode, messages, headerMessageId) { if (fire.wakeup) { await fire.wakeup(); } let win = windowManager.wrapWindow(window); let saveInfo = { mode, messages }; return fire.async( tabManager.convert(win.activeTab.nativeTab), saveInfo ); }, async onFailure(window, mode, exception) { if (fire.wakeup) { await fire.wakeup(); } let win = windowManager.wrapWindow(window); return fire.async(tabManager.convert(win.activeTab.nativeTab), { mode, messages: [], error: exception.message, }); }, modes: ["draft", "template"], extension, }; afterSaveSendEventTracker.addListener(listener); return { unregister: () => { afterSaveSendEventTracker.removeListener(listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, onAttachmentAdded({ context, fire }) { const { extension } = this; const { tabManager } = extension; async function listener(event) { if (fire.wakeup) { await fire.wakeup(); } for (let attachment of event.detail) { attachment = composeAttachmentTracker.convert( attachment, event.target.ownerGlobal ); fire.async(tabManager.convert(event.target.ownerGlobal), attachment); } } windowTracker.addListener("attachments-added", listener); return { unregister: () => { windowTracker.removeListener("attachments-added", listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, onAttachmentRemoved({ context, fire }) { const { extension } = this; const { tabManager } = extension; async function listener(event) { if (fire.wakeup) { await fire.wakeup(); } for (let attachment of event.detail) { let attachmentId = composeAttachmentTracker.getId( attachment, event.target.ownerGlobal ); fire.async( tabManager.convert(event.target.ownerGlobal), attachmentId ); composeAttachmentTracker.forgetAttachment(attachment); } } windowTracker.addListener("attachments-removed", listener); return { unregister: () => { windowTracker.removeListener("attachments-removed", listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, onIdentityChanged({ context, fire }) { const { extension } = this; const { tabManager } = extension; async function listener(event) { if (fire.wakeup) { await fire.wakeup(); } fire.async( tabManager.convert(event.target.ownerGlobal), event.target.getCurrentIdentityKey() ); } windowTracker.addListener("compose-from-changed", listener); return { unregister: () => { windowTracker.removeListener("compose-from-changed", listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, onComposeStateChanged({ context, fire }) { const { extension } = this; const { tabManager } = extension; async function listener(event) { if (fire.wakeup) { await fire.wakeup(); } fire.async( tabManager.convert(event.target.ownerGlobal), composeStates.convert(event.detail) ); } windowTracker.addListener("compose-state-changed", listener); return { unregister: () => { windowTracker.removeListener("compose-state-changed", listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, onActiveDictionariesChanged({ context, fire }) { const { extension } = this; const { tabManager } = extension; async function listener(event) { if (fire.wakeup) { await fire.wakeup(); } let activeDictionaries = event.detail.split(","); fire.async( tabManager.convert(event.target.ownerGlobal), Cc["@mozilla.org/spellchecker/engine;1"] .getService(Ci.mozISpellCheckingEngine) .getDictionaryList() .reduce((list, dict) => { list[dict] = activeDictionaries.includes(dict); return list; }, {}) ); } windowTracker.addListener("active-dictionaries-changed", listener); return { unregister: () => { windowTracker.removeListener("active-dictionaries-changed", listener); }, convert(newFire, extContext) { fire = newFire; context = extContext; }, }; }, }; getAPI(context) { /** * Guard to make sure the API waits until the compose tab has been fully loaded, * to cope with tabs.onCreated returning tabs very early. * * @param {integer} tabId * @returns {Tab} a fully loaded messageCompose tab */ async function getComposeTab(tabId) { let tab = tabManager.get(tabId); if (tab.type != "messageCompose") { throw new ExtensionError(`Invalid compose tab: ${tabId}`); } await composeWindowIsReady(tab.nativeTab); return tab; } let { extension } = context; let { tabManager } = extension; return { compose: { onBeforeSend: new EventManager({ context, module: "compose", event: "onBeforeSend", inputHandling: true, extensionApi: this, }).api(), onAfterSend: new EventManager({ context, module: "compose", event: "onAfterSend", inputHandling: true, extensionApi: this, }).api(), onAfterSave: new EventManager({ context, module: "compose", event: "onAfterSave", inputHandling: true, extensionApi: this, }).api(), onAttachmentAdded: new ExtensionCommon.EventManager({ context, module: "compose", event: "onAttachmentAdded", extensionApi: this, }).api(), onAttachmentRemoved: new ExtensionCommon.EventManager({ context, module: "compose", event: "onAttachmentRemoved", extensionApi: this, }).api(), onIdentityChanged: new ExtensionCommon.EventManager({ context, module: "compose", event: "onIdentityChanged", extensionApi: this, }).api(), onComposeStateChanged: new ExtensionCommon.EventManager({ context, module: "compose", event: "onComposeStateChanged", extensionApi: this, }).api(), onActiveDictionariesChanged: new ExtensionCommon.EventManager({ context, module: "compose", event: "onActiveDictionariesChanged", extensionApi: this, }).api(), async beginNew(messageId, details) { let type = Ci.nsIMsgCompType.New; if (messageId) { let msgHdr = messageTracker.getMessage(messageId); type = msgHdr.flags & Ci.nsMsgMessageFlags.Template ? Ci.nsIMsgCompType.Template : Ci.nsIMsgCompType.EditAsNew; } let composeWindow = await openComposeWindow( messageId, type, details, extension ); return tabManager.convert(composeWindow); }, async beginReply(messageId, replyType, details) { let type = Ci.nsIMsgCompType.Reply; if (replyType == "replyToList") { type = Ci.nsIMsgCompType.ReplyToList; } else if (replyType == "replyToAll") { type = Ci.nsIMsgCompType.ReplyAll; } let composeWindow = await openComposeWindow( messageId, type, details, extension ); return tabManager.convert(composeWindow); }, async beginForward(messageId, forwardType, details) { let type = Ci.nsIMsgCompType.ForwardInline; if (forwardType == "forwardAsAttachment") { type = Ci.nsIMsgCompType.ForwardAsAttachment; } else if ( forwardType === null && Services.prefs.getIntPref("mail.forward_message_mode") == 0 ) { type = Ci.nsIMsgCompType.ForwardAsAttachment; } let composeWindow = await openComposeWindow( messageId, type, details, extension ); return tabManager.convert(composeWindow); }, async saveMessage(tabId, options) { let tab = await getComposeTab(tabId); let saveMode = options?.mode || "draft"; try { return await goDoCommand( tab.nativeTab, context.extension, saveMode ); } catch (ex) { throw new ExtensionError( `compose.saveMessage failed: ${ex.message}` ); } }, async sendMessage(tabId, options) { let tab = await getComposeTab(tabId); let sendMode = options?.mode; if (!["sendLater", "sendNow"].includes(sendMode)) { sendMode = Services.io.offline ? "sendLater" : "sendNow"; } try { return await goDoCommand( tab.nativeTab, context.extension, sendMode ); } catch (ex) { throw new ExtensionError( `compose.sendMessage failed: ${ex.message}` ); } }, async getComposeState(tabId) { let tab = await getComposeTab(tabId); return composeStates.getStates(tab); }, async getComposeDetails(tabId) { let tab = await getComposeTab(tabId); return getComposeDetails(tab.nativeTab, extension); }, async setComposeDetails(tabId, details) { let tab = await getComposeTab(tabId); return setComposeDetails(tab.nativeTab, details, extension); }, async getActiveDictionaries(tabId) { let tab = await getComposeTab(tabId); let dictionaries = tab.nativeTab.gActiveDictionaries; // Return the list of installed dictionaries, setting those who are // enabled to true. return Cc["@mozilla.org/spellchecker/engine;1"] .getService(Ci.mozISpellCheckingEngine) .getDictionaryList() .reduce((list, dict) => { list[dict] = dictionaries.has(dict); return list; }, {}); }, async setActiveDictionaries(tabId, activeDictionaries) { let tab = await getComposeTab(tabId); let installedDictionaries = Cc["@mozilla.org/spellchecker/engine;1"] .getService(Ci.mozISpellCheckingEngine) .getDictionaryList(); for (let dict of activeDictionaries) { if (!installedDictionaries.includes(dict)) { throw new ExtensionError(`Dictionary not found: ${dict}`); } } await tab.nativeTab.ComposeChangeLanguage(activeDictionaries); }, async listAttachments(tabId) { let tab = await getComposeTab(tabId); let bucket = tab.nativeTab.document.getElementById("attachmentBucket"); let attachments = []; for (let item of bucket.itemChildren) { attachments.push( composeAttachmentTracker.convert(item.attachment, tab.nativeTab) ); } return attachments; }, async getAttachmentFile(attachmentId) { if (!composeAttachmentTracker.hasAttachment(attachmentId)) { throw new ExtensionError(`Invalid attachment: ${attachmentId}`); } let { attachment } = composeAttachmentTracker.getAttachment(attachmentId); return composeAttachmentTracker.getFile(attachment); }, async addAttachment(tabId, data) { let tab = await getComposeTab(tabId); let attachmentData = await createAttachment(data); await AddAttachmentsToWindow(tab.nativeTab, [attachmentData]); return composeAttachmentTracker.convert( attachmentData.attachment, tab.nativeTab ); }, async updateAttachment(tabId, attachmentId, data) { let tab = await getComposeTab(tabId); if (!composeAttachmentTracker.hasAttachment(attachmentId)) { throw new ExtensionError(`Invalid attachment: ${attachmentId}`); } let { attachment, window } = composeAttachmentTracker.getAttachment(attachmentId); if (window != tab.nativeTab) { throw new ExtensionError( `Attachment ${attachmentId} is not associated with tab ${tabId}` ); } let attachmentItem = window.gAttachmentBucket.findItemForAttachment(attachment); if (!attachmentItem) { throw new ExtensionError(`Unexpected invalid attachment item`); } if (!data.file && !data.name) { throw new ExtensionError( `Either data.file or data.name property must be specified` ); } let realFile = data.file ? await getRealFileForFile(data.file) : null; try { await window.UpdateAttachment(attachmentItem, { file: realFile, name: data.name, relatedCloudFileUpload: attachmentItem.cloudFileUpload, }); } catch (ex) { throw new ExtensionError(ex.message); } return composeAttachmentTracker.convert(attachmentItem.attachment); }, async removeAttachment(tabId, attachmentId) { let tab = await getComposeTab(tabId); if (!composeAttachmentTracker.hasAttachment(attachmentId)) { throw new ExtensionError(`Invalid attachment: ${attachmentId}`); } let { attachment, window } = composeAttachmentTracker.getAttachment(attachmentId); if (window != tab.nativeTab) { throw new ExtensionError( `Attachment ${attachmentId} is not associated with tab ${tabId}` ); } let item = window.gAttachmentBucket.findItemForAttachment(attachment); await window.RemoveAttachments([item]); }, }, }; } };