/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { MailServices } from "resource:///modules/MailServices.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { collectSingleAddress: "resource:///modules/AddressCollector.sys.mjs", MailUtils: "resource:///modules/MailUtils.sys.mjs", MimeMessage: "resource:///modules/MimeMessage.sys.mjs", MsgUtils: "resource:///modules/MimeMessageUtils.sys.mjs", jsmime: "resource:///modules/jsmime.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); // nsMsgKey_None from MailNewsTypes.h. const nsMsgKey_None = 0xffffffff; /** * A class to manage sending processes. * * @implements {nsIMsgSend} * @implements {nsIWebProgressListener} */ export class MessageSend { QueryInterface = ChromeUtils.generateQI([ "nsIMsgSend", "nsIWebProgressListener", "nsISupportsWeakReference", ]); classID = Components.ID("{028b9c1e-8d0a-4518-80c2-842e07846eaa}"); /** * Create an rfc822 message and send it. * @param {nsIEditor} editor - nsIEditor instance that contains message. * May be a dummy, especially in the case of import. * @param {nsIMsgIdentity} userIdentity - Identity to send from. * @param {?string} accountKey - Account we're sending message from. * @param {nsIMsgCompFields} compFields - Composition fields. * @param {boolean} isDigest - Is this a digest message? * @param {boolean} dontDeliver - Set to false by the import code - * used when we're trying to create a message from parts. * @param {nsMsgDeliverMode} deliverMode - Delivery mode. * @param {?nsIMsgDBHdr} msgToReplace - E.g., when saving a draft over an old draft. * @param {string} bodyType - Content type of message body. * @param {string} body - Message body text (should have native line endings) * @param {?mozIDOMWindowProxy} parentWindow - Compose window. * @param {?nsIMsgProgress} progress - Where to send progress info. * @param {?nsIMsgSendListener} listener - Optional listener for send progress. * @param {?string} smtpPassword - Optional smtp server password * @param {?string} originalMsgURI - URI of original message. * @param {nsIMsgCompType} compType - Compose type. * @returns {Promise} promise when the create and send process is done. */ async createAndSendMessage( editor, userIdentity, accountKey, compFields, isDigest, dontDeliver, deliverMode, msgToReplace, bodyType, body, parentWindow, progress, listener, smtpPassword, originalMsgURI, compType ) { this._userIdentity = userIdentity; this._accountKey = accountKey || this._accountKeyForIdentity(userIdentity); this._compFields = compFields; this._dontDeliver = dontDeliver; this._deliverMode = deliverMode; this._msgToReplace = msgToReplace; this._sendProgress = progress; this._smtpPassword = smtpPassword; this._sendListener = listener; this._parentWindow = parentWindow || Services.wm.getMostRecentWindow("msgcompose"); this._originalMsgURI = originalMsgURI; this._compType = compType; this._shouldRemoveMessageFile = true; this._sendReport = Cc[ "@mozilla.org/messengercompose/sendreport;1" ].createInstance(Ci.nsIMsgSendReport); this._composeBundle = Services.strings.createBundle( "chrome://messenger/locale/messengercompose/composeMsgs.properties" ); // Initialize the error reporting mechanism. this.sendReport.reset(); this.sendReport.deliveryMode = deliverMode; this._setStatusMessage( this._composeBundle.GetStringFromName("assemblingMailInformation") ); this.sendReport.currentProcess = Ci.nsIMsgSendReport.process_BuildMessage; this._setStatusMessage( this._composeBundle.GetStringFromName("assemblingMessage") ); this._fcc = lazy.MsgUtils.getFcc( userIdentity, compFields, originalMsgURI, compType ); const { embeddedAttachments, embeddedObjects } = this._gatherEmbeddedAttachments(editor); let bodyText = this._getBodyFromEditor(editor) || body; // Convert to a binary string. This is because MimeMessage requires it and: // 1. An attachment content is BinaryString. // 2. Body text and attachment contents are handled in the same way by // MimeEncoder to pick encoding and encode. bodyText = lazy.jsmime.mimeutils.typedArrayToString( new TextEncoder().encode(bodyText) ); this._restoreEditorContent(embeddedObjects); this._message = new lazy.MimeMessage( userIdentity, compFields, this._fcc, bodyType, bodyText, deliverMode, originalMsgURI, compType, embeddedAttachments, this.sendReport ); this._messageKey = nsMsgKey_None; this._setStatusMessage( this._composeBundle.GetStringFromName("creatingMailMessage") ); lazy.MsgUtils.sendLogger.debug("Creating message file"); let messageFile; try { // Create a local file from MimeMessage, then pass it to _deliverMessage. messageFile = await this._message.createMessageFile(); } catch (e) { lazy.MsgUtils.sendLogger.error(e); let errorMsg = ""; if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) { errorMsg = this._composeBundle.formatStringFromName( "errorAttachingFile", [e.data.name || e.data.url] ); } this.fail(e.result || Cr.NS_ERROR_FAILURE, errorMsg); this.notifyListenerOnStopSending(null, e.result, null, null); return null; } this._setStatusMessage( this._composeBundle.GetStringFromName("assemblingMessageDone") ); lazy.MsgUtils.sendLogger.debug("Message file created"); return this._deliverMessage(messageFile); } /** * Sends a file to the specified composition fields, via the user identity * provided. * * @param {nsIMsgIdentity} userIdentity - The user identity to use for sending * this email. * @param {string} accountKey - The key of the account that this message relates to. * @param {nsIMsgCompFields} compFields - An object containing information * on who to send the message to. * @param {nsIFile} messageFile - A reference to the file to send. * @param {boolean} deleteSendFileOnCompletion - Set to true if you want the * send file deleted once the message has been sent. * @param {boolean} digest - If this is a multipart message, this param * specifies whether the message is in digest or mixed format. * @param {nsMsgDeliverMode} deliverMode - The delivery mode for sending the * message (see above for values). * @param {?nsIMsgDBHdr} msgToReplace - A message header representing a * message to be replaced by the one sent. * @param {?nsIMsgSendListener} listener - An nsIMsgSendListener to receive * feedback on the current send status. This parameter can also support * the nsIMsgCopyServiceListener interface to receive notifications of copy * finishing e.g. after saving a message to the sent mail folder. * @param {string} smtpPassword - Pass this in to prevent a dialog if the * password is needed for secure transmission. */ async sendMessageFile( userIdentity, accountKey, compFields, messageFile, deleteSendFileOnCompletion, digest, deliverMode, msgToReplace, listener, smtpPassword ) { this._userIdentity = userIdentity; this._accountKey = accountKey || this._accountKeyForIdentity(userIdentity); this._compFields = compFields; this._deliverMode = deliverMode; this._msgToReplace = msgToReplace; this._smtpPassword = smtpPassword; this._sendListener = listener; this._shouldRemoveMessageFile = deleteSendFileOnCompletion; this._sendReport = Cc[ "@mozilla.org/messengercompose/sendreport;1" ].createInstance(Ci.nsIMsgSendReport); this._composeBundle = Services.strings.createBundle( "chrome://messenger/locale/messengercompose/composeMsgs.properties" ); // Initialize the error reporting mechanism. this.sendReport.reset(); this.sendReport.deliveryMode = deliverMode; this._setStatusMessage( this._composeBundle.GetStringFromName("assemblingMailInformation") ); this.sendReport.currentProcess = Ci.nsIMsgSendReport.process_BuildMessage; this._setStatusMessage( this._composeBundle.GetStringFromName("assemblingMessage") ); this._fcc = lazy.MsgUtils.getFcc( userIdentity, compFields, null, Ci.nsIMsgCompType.New ); // nsMsgKey_None from MailNewsTypes.h. this._messageKey = 0xffffffff; return this._deliverMessage(messageFile); } // @see nsIMsgSend createRFC822Message( userIdentity, compFields, bodyType, bodyText, isDraft, attachedFiles, embeddedObjects, listener ) { this._userIdentity = userIdentity; this._compFields = compFields; this._dontDeliver = true; this._sendListener = listener; this._sendReport = Cc[ "@mozilla.org/messengercompose/sendreport;1" ].createInstance(Ci.nsIMsgSendReport); this._composeBundle = Services.strings.createBundle( "chrome://messenger/locale/messengercompose/composeMsgs.properties" ); // Initialize the error reporting mechanism. this.sendReport.reset(); const deliverMode = isDraft ? Ci.nsIMsgSend.nsMsgSaveAsDraft : Ci.nsIMsgSend.nsMsgDeliverNow; this.sendReport.deliveryMode = deliverMode; // Convert nsIMsgAttachedFile[] to nsIMsgAttachment[] for (const file of attachedFiles) { const attachment = Cc[ "@mozilla.org/messengercompose/attachment;1" ].createInstance(Ci.nsIMsgAttachment); attachment.name = file.realName; attachment.url = file.origUrl.spec; attachment.contentType = file.type; compFields.addAttachment(attachment); } // Convert nsIMsgEmbeddedImageData[] to nsIMsgAttachment[] const embeddedAttachments = embeddedObjects.map(obj => { const attachment = Cc[ "@mozilla.org/messengercompose/attachment;1" ].createInstance(Ci.nsIMsgAttachment); attachment.name = obj.name; attachment.contentId = obj.cid; attachment.url = obj.uri.spec; return attachment; }); this._message = new lazy.MimeMessage( userIdentity, compFields, null, bodyType, bodyText, deliverMode, null, Ci.nsIMsgCompType.New, embeddedAttachments, this.sendReport ); this._messageKey = nsMsgKey_None; // Create a local file from MimeMessage, then pass it to _deliverMessage. this._message .createMessageFile() .then(messageFile => this._deliverMessage(messageFile)); } // nsIWebProgressListener. onLocationChange() {} onProgressChange() {} onStatusChange() {} onSecurityChange() {} onContentBlockingEvent() {} onStateChange(webProgress, request, stateFlags, status) { if ( stateFlags & Ci.nsIWebProgressListener.STATE_STOP && !Components.isSuccessCode(status) ) { lazy.MsgUtils.sendLogger.debug("onStateChange with failure. Aborting."); this._isRetry = false; this.abort(); } } abort() { if (this._aborting) { return; } this._aborting = true; if (this._outgoingListener) { this._outgoingListener.cancel(Cr.NS_ERROR_ABORT); } if (this._msgCopy) { MailServices.copy.notifyCompletion( this._copyFile, this._msgCopy.dstFolder, Cr.NS_ERROR_ABORT ); } else { // If already in the fcc step, notifyListenerOnStopCopy will do the clean up. this._cleanup(); } if (!this._failed) { // Emit stopsending event if the sending is cancelled by user, so that // listeners can do necessary clean up, e.g. reset the sending button. this.notifyListenerOnStopSending(null, Cr.NS_ERROR_ABORT, null, null); } this._aborting = false; } fail(exitCode, errorMsg) { this._failed = true; if (!Components.isSuccessCode(exitCode) && exitCode != Cr.NS_ERROR_ABORT) { lazy.MsgUtils.sendLogger.error( `Sending failed; ${errorMsg}, exitCode=${exitCode}, originalMsgURI=${this._originalMsgURI}` ); if (errorMsg) { this._sendReport.errMessage = errorMsg; } this._sendReport.displayReport(this._parentWindow); } this.abort(); } getProgress() { return this._sendProgress; } /** * NOTE: This is a copy of the C++ code, msgId and msgSize are only * placeholders. Maybe refactor this after nsMsgSend is gone. */ notifyListenerOnStartSending(msgId, msgSize) { lazy.MsgUtils.sendLogger.debug("notifyListenerOnStartSending"); if (this._sendListener) { this._sendListener.onStartSending(msgId, msgSize); } } notifyListenerOnStartCopy() { lazy.MsgUtils.sendLogger.debug("notifyListenerOnStartCopy"); if (this._sendListener instanceof Ci.nsIMsgCopyServiceListener) { this._sendListener.onStartCopy(); } } notifyListenerOnProgressCopy(progress, progressMax) { lazy.MsgUtils.sendLogger.debug("notifyListenerOnProgressCopy"); if (this._sendListener instanceof Ci.nsIMsgCopyServiceListener) { this._sendListener.onProgress(progress, progressMax); } } async notifyListenerOnStopCopy(status) { lazy.MsgUtils.sendLogger.debug( `notifyListenerOnStopCopy; status=${status}` ); this._msgCopy = null; if (!this._isRetry) { const statusMsgEntry = Components.isSuccessCode(status) ? "copyMessageComplete" : "copyMessageFailed"; this._setStatusMessage( this._composeBundle.GetStringFromName(statusMsgEntry) ); } else if (Components.isSuccessCode(status)) { // We got here via retry and the save to sent, drafts or template // succeeded so take down our progress dialog. We don't need it any more. this._sendProgress.unregisterListener(this); this._sendProgress.closeProgressDialog(false); this._isRetry = false; } if (!Components.isSuccessCode(status)) { const localFoldersAccountName = MailServices.accounts.localFoldersServer.prettyName; const folder = lazy.MailUtils.getOrCreateFolder(this._folderUri); const accountName = folder?.server.prettyName; if (!this._fcc || !localFoldersAccountName || !accountName) { this.fail(Cr.NS_OK, null); return; } const params = [ folder.localizedName, accountName, localFoldersAccountName, ]; let promptMsg; switch (this._deliverMode) { case Ci.nsIMsgSend.nsMsgDeliverNow: case Ci.nsIMsgSend.nsMsgSendUnsent: promptMsg = this._composeBundle.formatStringFromName( "promptToSaveSentLocally2", params ); break; case Ci.nsIMsgSend.nsMsgSaveAsDraft: promptMsg = this._composeBundle.formatStringFromName( "promptToSaveDraftLocally2", params ); break; case Ci.nsIMsgSend.nsMsgSaveAsTemplate: promptMsg = this._composeBundle.formatStringFromName( "promptToSaveTemplateLocally2", params ); break; } if (promptMsg) { const showCheckBox = { value: false }; const buttonFlags = Ci.nsIPrompt.BUTTON_POS_0 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING + Ci.nsIPrompt.BUTTON_POS_1 * Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE + Ci.nsIPrompt.BUTTON_POS_2 * Ci.nsIPrompt.BUTTON_TITLE_SAVE; const dialogTitle = this._composeBundle.GetStringFromName("SaveDialogTitle"); const buttonLabelRety = this._composeBundle.GetStringFromName("buttonLabelRetry2"); const buttonPressed = Services.prompt.confirmEx( this._parentWindow, dialogTitle, promptMsg, buttonFlags, buttonLabelRety, null, null, null, showCheckBox ); if (buttonPressed == 0) { // retry button clicked if ( this._sendProgress?.processCanceledByUser && Services.prefs.getBoolPref("mailnews.show_send_progress") ) { // We had a progress dialog and the user cancelled it, create a // new one. const progress = Cc[ "@mozilla.org/messenger/progress;1" ].createInstance(Ci.nsIMsgProgress); const composeParams = Cc[ "@mozilla.org/messengercompose/composeprogressparameters;1" ].createInstance(Ci.nsIMsgComposeProgressParams); composeParams.subject = this._parentWindow.gMsgCompose.compFields.subject; composeParams.deliveryMode = this._deliverMode; progress.openProgressDialog( this._parentWindow, "chrome://messenger/content/messengercompose/sendProgress.xhtml", composeParams ); progress.onStateChange( null, null, Ci.nsIWebProgressListener.STATE_START, Cr.NS_OK ); // We want to hear when this is cancelled. progress.registerListener(this); this._sendProgress = progress; this._isRetry = true; } await this._mimeDoFcc(); return; } else if (buttonPressed == 2) { try { // Try to save to Local Folders/. Pass null to save // to local folders and not the configured fcc. await this._mimeDoFcc(null, true, Ci.nsIMsgSend.nsMsgDeliverNow); return; } catch (e) { Services.prompt.alert( this._parentWindow, null, this._composeBundle.GetStringFromName("saveToLocalFoldersFailed") ); } } } // A failed or declined primary FCC copy must not skip the additional // folder copy. await this._doFcc2(); return; } if ( !this._fcc2Handled && this._messageKey != nsMsgKey_None && [Ci.nsIMsgSend.nsMsgDeliverNow, Ci.nsIMsgSend.nsMsgSendUnsent].includes( this._deliverMode ) ) { // Sent-message filters finish asynchronously in onStopOperation. Wait for // that callback so FCC2 remains ordered after the filters. const { promise, resolve, reject } = Promise.withResolvers(); this._filterCompletionResolvers = { resolve, reject }; try { this._filterSentMessage(); } catch (e) { await this.onStopOperation(e.result); } await promise; return; } await this._doFcc2(); } notifyListenerOnStopSending(msgId, status, msg, returnFile) { lazy.MsgUtils.sendLogger.debug( `notifyListenerOnStopSending; status=${status}` ); try { this._sendListener?.onStopSending(msgId, status, msg, returnFile); } catch (e) {} } notifyListenerOnTransportSecurityError(msgId, status, secInfo, location) { lazy.MsgUtils.sendLogger.debug( `notifyListenerOnTransportSecurityError; status=${status}, location=${location}` ); if (!this._sendListener) { return; } try { this._sendListener.onTransportSecurityError( msgId, status, secInfo, location ); } catch (e) {} } /** * Called by nsIMsgFilterService. */ async onStopOperation(status) { lazy.MsgUtils.sendLogger.debug(`onStopOperation; status=${status}`); if (Components.isSuccessCode(status)) { this._setStatusMessage( this._composeBundle.GetStringFromName("filterMessageComplete") ); } else { this._setStatusMessage( this._composeBundle.GetStringFromName("filterMessageFailed") ); Services.prompt.alert( this._parentWindow, null, this._composeBundle.GetStringFromName("errorFilteringMsg") ); } try { await this._doFcc2(); this._filterCompletionResolvers?.resolve(); } catch (e) { // The awaiter in _doFcc surfaces this via the rejected promise; don't // also throw, or the ignored onStopOperation promise becomes an // unhandled rejection. this._filterCompletionResolvers?.reject(e); } finally { this._filterCompletionResolvers = null; } } /** * Handle the exit code of message delivery. * * @param {nsIURI} serverURI - The URI of the server used for the delivery. * @param {nsresult} exitCode - The exit code of message delivery. * @param {?nsITransportSecurityInfo} secInfo - The info to use in case of a security error. * @param {string} errMsg - A localized error message. * @param {boolean} isNewsDelivery - The message was delivered to newsgroup. */ async _deliveryExitProcessing( serverURI, exitCode, secInfo, errMsg, isNewsDelivery ) { lazy.MsgUtils.sendLogger.debug( `Delivery exit processing; exitCode=${exitCode}` ); if (!Components.isSuccessCode(exitCode)) { let isNSSError = false; let isOverridable = false; const nssErrorsService = Cc[ "@mozilla.org/nss_errors_service;1" ].getService(Ci.nsINSSErrorsService); if (secInfo?.errorCode) { if (nssErrorsService.isNSSErrorCode(secInfo.errorCode)) { isNSSError = true; exitCode = nssErrorsService.getXPCOMFromNSSError(secInfo.errorCode); isOverridable = nssErrorsService.isErrorOverridable(exitCode); } } let errorMsg; if (!isNSSError) { const errorName = lazy.MsgUtils.getErrorStringName(exitCode); if (exitCode == Cr.NS_ERROR_FAILURE) { errorMsg = errMsg; } else if ( [ Cr.NS_ERROR_UNKNOWN_HOST, Cr.NS_ERROR_UNKNOWN_PROXY_HOST, Cr.NS_ERROR_CONNECTION_REFUSED, Cr.NS_ERROR_PROXY_CONNECTION_REFUSED, Cr.NS_ERROR_NET_INTERRUPT, Cr.NS_ERROR_NET_TIMEOUT, Cr.NS_ERROR_NET_RESET, ].includes(exitCode) ) { errorMsg = lazy.MsgUtils.formatStringWithSMTPHostName( this._userIdentity, this._composeBundle, errorName ); } else if (errMsg) { // errMsg is an already localized message, usually combined with the // error message from SMTP server. errorMsg = errMsg; } else { // May be the default string "sendFailed". Should be and error that // does require the server name to be encoded. errorMsg = this._composeBundle.GetStringFromName(errorName); } } else { // This is a server security issue as determined by the Mozilla // platform. To the Mozilla security message string, appended a string // having additional information with the server name encoded. errorMsg = nssErrorsService.getErrorMessage(exitCode); errorMsg += "\n" + lazy.MsgUtils.formatStringWithSMTPHostName( this._userIdentity, this._composeBundle, "smtpSecurityIssue" ); } this.notifyListenerOnStopSending(null, exitCode, null, null); this.fail(exitCode, errorMsg); if (isNSSError && isOverridable) { this.notifyListenerOnTransportSecurityError( null, exitCode, secInfo, serverURI.asciiHostPort ); } return; } if ( isNewsDelivery && (this._compFields.to || this._compFields.cc || this._compFields.bcc) ) { this._deliverAsMail(); return; } this.notifyListenerOnStopSending( this._compFields.messageId, exitCode, null, null ); await this._doFcc(); } async sendDeliveryCallback( serverURI, exitCode, secInfo, errMsg, isNewsDelivery = false ) { if (isNewsDelivery) { if ( !Components.isSuccessCode(exitCode) && exitCode != Cr.NS_ERROR_ABORT ) { exitCode = Cr.NS_ERROR_FAILURE; errMsg = this._composeBundle.GetStringFromName("postFailed"); } return await this._deliveryExitProcessing( serverURI, exitCode, secInfo, errMsg, isNewsDelivery ); } return await this._deliveryExitProcessing( serverURI, exitCode, secInfo, errMsg, isNewsDelivery ); } get folderUri() { return this._folderUri; } get messageId() { return this._compFields.messageId; } /** * @type {nsMsgKey} */ set messageKey(key) { this._messageKey = key; } /** * @type {nsMsgKey} */ get messageKey() { return this._messageKey; } get sendReport() { return this._sendReport; } _setStatusMessage(msg) { if (this._sendProgress) { this._sendProgress.onStatusChange(null, null, Cr.NS_OK, msg); } } /** * Deliver a message. * * @param {nsIFile} file - The message file to deliver. */ async _deliverMessage(file) { if (this._dontDeliver) { this.notifyListenerOnStopSending(null, Cr.NS_OK, null, file); return; } this._messageFile = file; if ( [ Ci.nsIMsgSend.nsMsgQueueForLater, Ci.nsIMsgSend.nsMsgDeliverBackground, Ci.nsIMsgSend.nsMsgSaveAsDraft, Ci.nsIMsgSend.nsMsgSaveAsTemplate, ].includes(this._deliverMode) ) { await this._mimeDoFcc(); return; } const warningSize = Services.prefs.getIntPref( "mailnews.message_warning_size" ); if (warningSize > 0 && file.fileSize > warningSize) { const messenger = Cc["@mozilla.org/messenger;1"].createInstance( Ci.nsIMessenger ); const msg = this._composeBundle.formatStringFromName( "largeMessageSendWarning", [messenger.formatFileSize(file.fileSize)] ); if (!Services.prompt.confirm(this._parentWindow, null, msg)) { this.fail(Cr.NS_ERROR_ABORT); throw Components.Exception( "Cancelled sending large message", Cr.NS_ERROR_FAILURE ); } } this._deliveryFile = await this._createDeliveryFile(); if (this._compFields.newsgroups) { await this._deliverAsNews(); return; } await this._deliverAsMail(); } /** * Strip Bcc header, create the file to be actually delivered. * * @returns {nsIFile} */ async _createDeliveryFile() { if (!this._compFields.bcc) { return this._messageFile; } const deliveryFile = Services.dirsvc.get("TmpD", Ci.nsIFile); deliveryFile.append("nsemail.tmp"); deliveryFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); const content = await IOUtils.read(this._messageFile.path); const bodyIndex = content.findIndex( (el, index) => // header and body are separated by \r\n\r\n el == 13 && content[index + 1] == 10 && content[index + 2] == 13 && content[index + 3] == 10 ); const header = new TextDecoder("UTF-8").decode(content.slice(0, bodyIndex)); let inBcc = false; let headerToWrite = ""; for (const line of header.split("\r\n")) { if (line.startsWith("Bcc:") || (line.startsWith(" ") && inBcc)) { inBcc = true; continue; } inBcc = false; headerToWrite += `${line}\r\n`; } const encodedHeader = new TextEncoder().encode(headerToWrite); // Prevent extra \r\n, which was already added to the last head line. const body = content.slice(bodyIndex + 2); const combinedContent = new Uint8Array(encodedHeader.length + body.length); combinedContent.set(encodedHeader); combinedContent.set(body, encodedHeader.length); await IOUtils.write(deliveryFile.path, combinedContent); return deliveryFile; } /** * Create the file to be copied to the Sent folder, add X-Mozilla-Status and * X-Mozilla-Status2 if needed. * * @returns {nsIFile} */ async _createCopyFile() { if (!this._folderUri.startsWith("mailbox:")) { return this._messageFile; } // Add a `From - Date` line, so that nsLocalMailFolder.cpp won't add a // dummy envelope. The date string will be parsed by PR_ParseTimeString. // TODO: this should not be added to Maildir, see bug 1686852. let contentToWrite = `From - ${new Date().toUTCString()}\r\n`; const xMozillaStatus = lazy.MsgUtils.getXMozillaStatus(this._deliverMode); const xMozillaStatus2 = lazy.MsgUtils.getXMozillaStatus2(this._deliverMode); if (xMozillaStatus) { contentToWrite += `X-Mozilla-Status: ${xMozillaStatus}\r\n`; } if (xMozillaStatus2) { contentToWrite += `X-Mozilla-Status2: ${xMozillaStatus2}\r\n`; } // Create a separate copy file when there are extra headers. const copyFile = Services.dirsvc.get("TmpD", Ci.nsIFile); copyFile.append("nscopy.tmp"); copyFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); await IOUtils.writeUTF8(copyFile.path, contentToWrite); await IOUtils.write( copyFile.path, await IOUtils.read(this._messageFile.path), { mode: "append", } ); return copyFile; } /** * Start copy operation according to this._fcc value. */ async _doFcc() { if (!this._fcc || !lazy.MsgUtils.canSaveToFolder(this._fcc)) { await this.notifyListenerOnStopCopy(Cr.NS_OK); return; } this.sendReport.currentProcess = Ci.nsIMsgSendReport.process_Copy; await this._mimeDoFcc(this._fcc, false, Ci.nsIMsgSend.nsMsgDeliverNow); } /** * Copy a message to a folder, or fallback to a folder depending on pref and * deliverMode, usually Drafts/Sent. * * @param {string} [fccHeader=this._fcc] - The target folder uri to copy the * message to. * @param {boolean} [throwOnError=false] - By default notifyListenerOnStopCopy * is called on error. When throwOnError is true, the caller can handle the * error by itself. * @param {nsMsgDeliverMode} [deliverMode=this._deliverMode] - The deliver mode. */ async _mimeDoFcc( fccHeader = this._fcc, throwOnError = false, deliverMode = this._deliverMode ) { let folder; let folderUri; if (fccHeader) { folder = lazy.MailUtils.getExistingFolder(fccHeader); } if ( [Ci.nsIMsgSend.nsMsgDeliverNow, Ci.nsIMsgSend.nsMsgSendUnsent].includes( deliverMode ) && folder ) { this._folderUri = fccHeader; } else if (fccHeader == null) { // Set fcc_header to a special folder in Local Folders "account" since can't // save to Sent mbox, typically because imap connection is down. This // folder is created if it doesn't yet exist. const rootFolder = MailServices.accounts.localFoldersServer.rootMsgFolder; folderUri = rootFolder.URI + "/"; // Now append the special folder name folder to the local folder uri. if ( [ Ci.nsIMsgSend.nsMsgDeliverNow, Ci.nsIMsgSend.nsMsgSendUnsent, Ci.nsIMsgSend.nsMsgSaveAsDraft, Ci.nsIMsgSend.nsMsgSaveAsTemplate, ].includes(this._deliverMode) ) { // Typically, this appends "Sent-", "Drafts-" or "Templates-" to folder // and then has the account name appended, e.g., .../Sent-MyImapAccount. const localFolder = lazy.MailUtils.getOrCreateFolder(this._folderUri); folderUri += localFolder.localizedName + "-"; } if (this._fcc) { // Get the account name where the "save to" failed. const accountName = lazy.MailUtils.getOrCreateFolder(this._fcc).server .prettyName; // Now append the imap account name (escaped) to the folder uri. folderUri += accountName; this._folderUri = folderUri; } } else { this._folderUri = lazy.MsgUtils.getMsgFolderURIFromPrefs( this._userIdentity, this._deliverMode ); if ( (this._deliverMode == Ci.nsIMsgSend.nsMsgSaveAsDraft && this._compFields.draftId) || (this._deliverMode == Ci.nsIMsgSend.nsMsgSaveAsTemplate && this._compFields.templateId) ) { // Turn the draft/template ID into a folder URI string. const messenger = Cc["@mozilla.org/messenger;1"].createInstance( Ci.nsIMessenger ); try { // This can fail if the user renames/removed/moved the folder. folderUri = messenger.msgHdrFromURI( this._deliverMode == Ci.nsIMsgSend.nsMsgSaveAsDraft ? this._compFields.draftId : this._compFields.templateId ).folder.URI; } catch (ex) { console.warn(ex); } // Only accept it if it's a subfolder of the identity's draft/template folder. if (folderUri?.startsWith(this._folderUri)) { this._folderUri = folderUri; } } } lazy.MsgUtils.sendLogger.debug( `Processing fcc; folderUri=${this._folderUri}` ); this._msgCopy = Cc[ "@mozilla.org/messengercompose/msgcopy;1" ].createInstance(Ci.nsIMsgCopy); this._copyFile = await this._createCopyFile(); lazy.MsgUtils.sendLogger.debug("fcc file created"); // Notify nsMsgCompose about the saved folder. this._sendListener?.onGetDraftFolderURI( this._compFields.messageId, this._folderUri ); folder = lazy.MailUtils.getOrCreateFolder(this._folderUri); const statusMsg = this._composeBundle.formatStringFromName( "copyMessageStart", [folder?.localizedName || "?"] ); this._setStatusMessage(statusMsg); lazy.MsgUtils.sendLogger.debug("startCopyOperation"); try { this._msgCopy.startCopyOperation( this._userIdentity, this._copyFile, deliverMode, this, this._folderUri, this._msgToReplace ); } catch (e) { lazy.MsgUtils.sendLogger.warn( `startCopyOperation failed with ${e.result}` ); if (throwOnError) { throw Components.Exception("startCopyOperation failed", e.result); } await this.notifyListenerOnStopCopy(e.result); } } /** * Handle the fcc2 field. Then notify OnStopCopy and clean up. */ async _doFcc2() { // Handle fcc2 only once. if ( !this._fcc2Handled && this._compFields.fcc2 && [ Ci.nsIMsgSend.nsMsgDeliverNow, Ci.nsIMsgSend.nsMsgQueueForLater, ].includes(this._deliverMode) ) { lazy.MsgUtils.sendLogger.debug("Processing fcc2"); this._fcc2Handled = true; await this._mimeDoFcc( this._compFields.fcc2, false, Ci.nsIMsgSend.nsMsgDeliverNow ); return; } // NOTE: When nsMsgCompose receives OnStopCopy, it will release nsIMsgSend // instance and close the compose window, which prevents the Promise from // resolving in MsgComposeCommands.js. Use setTimeout to work around it. lazy.setTimeout(() => { try { if (this._sendListener instanceof Ci.nsIMsgCopyServiceListener) { this._sendListener.onStopCopy(0); } } catch (e) { // Ignore the return value of onStopCopy. Non-zero nsresult will throw // when going through XPConnect. In this case, we don't care about it. console.warn("onStopCopy failed", e); } this._cleanup(); }); } /** * Run filters on the just sent message. */ _filterSentMessage() { this.sendReport.currentProcess = Ci.nsIMsgSendReport.process_Filter; const folder = lazy.MailUtils.getExistingFolder(this._folderUri); const msgHdr = folder.GetMessageHeader(this._messageKey); const msgWindow = this._sendProgress?.msgWindow; return MailServices.filters.applyFilters( Ci.nsMsgFilterType.PostOutgoing, [msgHdr], [], folder, msgWindow, this ); } _cleanup() { lazy.MsgUtils.sendLogger.debug("Clean up temporary files"); if (this._copyFile && this._copyFile != this._messageFile) { IOUtils.remove(this._copyFile.path).catch(console.error); this._copyFile = null; } if (this._deliveryFile && this._deliveryFile != this._messageFile) { IOUtils.remove(this._deliveryFile.path).catch(console.error); this._deliveryFile = null; } if (this._messageFile && this._shouldRemoveMessageFile) { IOUtils.remove(this._messageFile.path).catch(console.error); this._messageFile = null; } } /** * Send this._deliveryFile to the outgoing service. */ async _deliverAsMail() { this.sendReport.currentProcess = Ci.nsIMsgSendReport.process_SMTP; this._setStatusMessage( this._composeBundle.GetStringFromName("sendingMessage") ); // Turn the `to` and `cc` comp fields (which are both strings) into one // continuous string, filtering out either of them if it's empty. const visibleRecipients = [this._compFields.to, this._compFields.cc] .filter(Boolean) .join(","); const parsedVisibleRecipients = MailServices.headerParser.parseEncodedHeaderW(visibleRecipients); // Parse the `bcc` comp field (a string) into a parsed array of // `msgIAddressObject`. let parsedBccRecipients = []; if (this._compFields.bcc) { parsedBccRecipients = MailServices.headerParser.parseEncodedHeaderW( this._compFields.bcc ); } // Collect all recipients into the address book at once. try { this._collectAddressesToAddressBook( [...parsedVisibleRecipients, ...parsedBccRecipients].filter(Boolean) ); } catch (e) { // Db access issues, etc. Not fatal for sending. lazy.MsgUtils.sendLogger.warn( `Collecting outgoing addresses FAILED: ${e.message}`, e ); } lazy.MsgUtils.sendLogger.debug( `Delivering mail message <${this._compFields.messageId}>` ); const outgoingListener = new PromiseMsgOutgoingListener(this); // Retrieve the relevant server to send this message from the outgoing // server service (and make sure it gave us one). const server = MailServices.outgoingServer.getServerByIdentity( this._userIdentity ); if (!server) { throw new Error(`No outgoing server for ${this._userIdentity.email}`); } // Keep the listener available for abort(): the cancelable request may not // exist until onSendStart runs. this._outgoingListener = outgoingListener; // Send the message using the server that was retrieved. server.sendMailMessage( this._deliveryFile, parsedVisibleRecipients, parsedBccRecipients, this._userIdentity, this._compFields.from, this._smtpPassword, this._sendProgress, this._compFields.DSN, this._compFields.messageId, outgoingListener ); await outgoingListener.requestPromise; } /** * Send this._deliveryFile to nntp service. */ async _deliverAsNews() { this.sendReport.currentProcess = Ci.nsIMsgSendReport.process_NNTP; lazy.MsgUtils.sendLogger.debug("Delivering news message"); const deliveryListener = new NewsDeliveryListener(this); let msgWindow; try { msgWindow = this._sendProgress?.msgWindow || MailServices.mailSession.topmostMsgWindow; } catch (e) {} MailServices.nntp.postMessage( this._deliveryFile, this._compFields.newsgroups, this._accountKey, deliveryListener, msgWindow, null ); await deliveryListener.requestPromise; } /** * Collect outgoing addresses to address book. * * @param {msgIAddressObject[]} addresses - Outgoing addresses including to/cc/bcc. */ _collectAddressesToAddressBook(addresses) { const createCard = Services.prefs.getBoolPref( "mail.collect_email_address_outgoing", false ); for (const addr of addresses) { let displayName = addr.name; // If we know this is a list, or it seems likely, don't collect the // displayName which may contain the sender's name instead of the (only) // name of the list. if ( this._compType == Ci.nsIMsgCompType.ReplyToList || addr.name.includes(" via ") ) { displayName = ""; } lazy.collectSingleAddress(addr.email, displayName, createCard); } } /** * Check if link text is equivalent to the href. * * @param {string} text - The innerHTML of a element. * @param {string} href - The href of a element. * @returns {boolean} true if text is equivalent to href. */ _isLinkFreeText(text, href) { href = href.trim(); if (href.startsWith("mailto:")) { return this._isLinkFreeText(text, href.slice("mailto:".length)); } text = text.trim(); return ( text == href || (text.endsWith("/") && text.slice(0, -1) == href) || (href.endsWith("/") && href.slice(0, -1) == text) ); } /** * Collect embedded objects as attachments. * * @returns {object} collected * @returns {nsIMsgAttachment[]} collected.embeddedAttachments * @returns {object[]} collected.embeddedObjects objects {element, url} */ _gatherEmbeddedAttachments(editor) { const embeddedAttachments = []; const embeddedObjects = []; if (!editor || !editor.document) { return { embeddedAttachments, embeddedObjects }; } const nodes = []; nodes.push(...editor.document.querySelectorAll("img")); nodes.push(...editor.document.querySelectorAll("a")); const body = editor.document.querySelector("body[background]"); if (body) { nodes.push(body); } const urlCidCache = {}; for (const element of nodes) { if (element.tagName == "A" && element.href) { if (this._isLinkFreeText(element.innerHTML, element.href)) { // Set this special classname, which is recognized by nsIParserUtils, // so that links are not duplicated in text/plain. element.classList.add("moz-txt-link-freetext"); } } let isImage = false; let url; let name; const mozDoNotSend = element.getAttribute("moz-do-not-send"); if (mozDoNotSend && mozDoNotSend != "false") { // Only empty or moz-do-not-send="false" may be accepted later. continue; } if (element.tagName == "BODY" && element.background) { isImage = true; url = element.background; } else if (element.tagName == "IMG" && element.src) { isImage = true; url = element.src; name = element.name; } else if (element.tagName == "A" && element.href) { url = element.href; name = element.name; } else { continue; } let shouldEmbed = false; // Before going further, check what scheme we're dealing with. Files need to // be converted to data URLs during composition. "Attaching" means // sending as a cid: part instead of original URL. if (/^https?:\/\//i.test(url)) { shouldEmbed = (isImage && Services.prefs.getBoolPref( "mail.compose.attach_http_images", false )) || mozDoNotSend == "false"; } if (/^(data|nntp):/i.test(url)) { shouldEmbed = true; } if (/^(news|snews):/i.test(url)) { shouldEmbed = mozDoNotSend == "false"; } if (!shouldEmbed) { continue; } let cid; if (urlCidCache[url]) { // If an url has already been inserted as MimePart, just reuse the cid. cid = urlCidCache[url]; } else { cid = lazy.MsgUtils.makeContentId( this._userIdentity, embeddedAttachments.length + 1 ); urlCidCache[url] = cid; const attachment = Cc[ "@mozilla.org/messengercompose/attachment;1" ].createInstance(Ci.nsIMsgAttachment); attachment.name = name || lazy.MsgUtils.pickFileNameFromUrl(url); attachment.contentId = cid; attachment.url = url; embeddedAttachments.push(attachment); } embeddedObjects.push({ element, url, }); const newUrl = `cid:${cid}`; if (element.tagName == "BODY") { element.background = newUrl; } else if (element.tagName == "IMG") { element.src = newUrl; } else if (element.tagName == "A") { element.href = newUrl; } } return { embeddedAttachments, embeddedObjects }; } /** * Restore embedded objects in editor to their original urls. * * @param {object[]} embeddedObjects - An array of embedded objects. * @param {Element} embeddedObjects.element * @param {string} embeddedObjects.url */ _restoreEditorContent(embeddedObjects) { for (const { element, url } of embeddedObjects) { if (element.tagName == "BODY") { element.background = url; } else if (element.tagName == "IMG") { element.src = url; } else if (element.tagName == "A") { element.href = url; } } } /** * Get the message body from an editor. * * @param {nsIEditor} editor - The editor instance. * @returns {string} */ _getBodyFromEditor(editor) { if (!editor) { return ""; } const flags = Ci.nsIDocumentEncoder.OutputFormatted | Ci.nsIDocumentEncoder.OutputNoFormattingInPre | Ci.nsIDocumentEncoder.OutputDisallowLineBreaking; // bodyText is UTF-16 string. let bodyText = editor.outputToString("text/html", flags); // No need to do conversion if forcing plain text. if (!this._compFields.forcePlainText) { const cs = Cc["@mozilla.org/txttohtmlconv;1"].getService( Ci.mozITXTToHTMLConv ); let csFlags = Ci.mozITXTToHTMLConv.kURLs; if (Services.prefs.getBoolPref("mail.send_struct", false)) { csFlags |= Ci.mozITXTToHTMLConv.kStructPhrase; } bodyText = cs.scanHTML(bodyText, csFlags); } return bodyText; } /** * Get the first account key of an identity. * * @param {nsIMsgIdentity} identity - The identity. * @returns {string} */ _accountKeyForIdentity(identity) { const servers = MailServices.accounts.getServersForIdentity(identity); return servers.length ? MailServices.accounts.findAccountForServer(servers[0])?.key : null; } } /** * A listener to be passed to the NNTP service. * * @implements {nsIUrlListener} */ class NewsDeliveryListener { QueryInterface = ChromeUtils.generateQI(["nsIUrlListener"]); #resolve; #reject; #requestPromise; /** * @param {nsIMsgSend} msgSend - nsIMsgSend instance to use. */ constructor(msgSend) { this._msgSend = msgSend; const { promise, resolve, reject } = Promise.withResolvers(); this.#requestPromise = promise; this.#resolve = resolve; this.#reject = reject; } OnStartRunningUrl() { this._msgSend.notifyListenerOnStartSending(null, 0); } async OnStopRunningUrl(url, exitCode) { lazy.MsgUtils.sendLogger.debug(`OnStopRunningUrl; exitCode=${exitCode}`); if (url instanceof Ci.nsIMsgMailNewsUrl) { url.UnRegisterListener(this); } // Await the callback to ensure state cleanup completes. await this._msgSend.sendDeliveryCallback(url, exitCode, null, null, true); if (Components.isSuccessCode(exitCode)) { this.#resolve(); } else { this.#reject( new Error(`NNTP delivery failed with exit code: ${exitCode}`) ); } } /** * A promise which resolves when the NNTP send attempt completes successfully, * or rejects if it fails. */ get requestPromise() { return this.#requestPromise; } } /** * A listener to be passed to an outgoing mail server. * * It provides a Promise which resolves to a request (of type `nsIRequest`) when * the message send begins. This request can be used to cancel the send attempt * if requested by the user. * * Upon start and stop of the send attempt, this listener also calls the * relevant callbacks on its `nsIMsgSend`. * * @implements {nsIMsgOutgoingListener} */ class PromiseMsgOutgoingListener { /** * The nsIMsgSend instance to notify on message send start/stop. * * @type {nsIMsgSend} */ #msgSend; /** * A promise that resolves to a request that can be used to cancel the message * send operation if requested. * * @type {Promise} */ #requestPromise; /** * The handle to resolve `#requestPromise`. * * @type {function(nsIRequest): void} */ #resolve; /** * The handle to reject `#requestPromise`. * * @type {function(Error): void} */ #reject; /** * @type {nsIRequest} */ #request; /** * A cancel status requested via cancel() before onSendStart had a cancelable * request. onSendStart applies it once the request exists. * * @type {nsresult} */ #pendingCancelStatus; QueryInterface = ChromeUtils.generateQI(["nsIMsgOutgoingListener"]); /** * @param {nsIMsgSend} msgSend - nsIMsgSend instance to notify on start/stop. */ constructor(msgSend) { this.#msgSend = msgSend; // We track the outcome of the send attempt, settled in onSendStop. const { promise, resolve, reject } = Promise.withResolvers(); this.#requestPromise = promise; this.#resolve = resolve; this.#reject = reject; } /** * Notifies that the send attempt has started, and resolves the inner promise. * * @param {nsIRequest} request - A request that can be used to cancel the send * attempt. */ onSendStart(request) { this.#request = request; this.#msgSend.notifyListenerOnStartSending(null, 0); if (this.#pendingCancelStatus !== undefined) { // Apply a cancellation that was requested before onSendStart. request.cancel(this.#pendingCancelStatus); this.#pendingCancelStatus = undefined; } } /** * Notifies that the send attempt has finished. * * @param {nsIURI} serverURI - The URI of the server that was used to send. * @param {nsresult} exitCode - The resulting status code for the send * attempt. * @param {?nsITransportSecurityInfo} secInfo - The security context for the * send attempt. * @param {?string} errMsg - An optional localized, human-readable error * message. */ async onSendStop(serverURI, exitCode, secInfo, errMsg) { if (this.#msgSend._outgoingListener == this) { this.#msgSend._outgoingListener = null; } await this.#msgSend.sendDeliveryCallback( serverURI, exitCode, secInfo, errMsg ); if (this.#msgSend._deliverMode == Ci.nsIMsgSend.nsMsgSendUnsent) { // FIXME: sendLater can't really handle promises... see bug 2032686. return; } if (Components.isSuccessCode(exitCode)) { this.#resolve(this.#request); } else { // Reject with an exception carrying the send status, so that // nsMsgCompose::SendMsg can recognize NS_ERROR_ABORT and skip the // failure alert when the user cancels their own send. this.#reject( new Components.Exception(`Sending FAILED! ${errMsg}`, exitCode) ); } } /** * A promise which resolves with an `nsIRequest`, which can be used to cancel * a send attempt. */ get requestPromise() { return this.#requestPromise; } /** * Cancel the outgoing request, or remember the cancellation until the request * exists. * * @param {nsresult} status - The cancellation status. */ cancel(status) { if (this.#request) { this.#request.cancel(status); } else { this.#pendingCancelStatus = status; } } } /** * @implements {nsIMsgSendReport} */ export class MessageSendReport { QueryInterface = ChromeUtils.generateQI(["nsIMsgSendReport"]); /** @type {integer} - see nsMsgDeliverMode in nsIMsgSend.idl for valid value */ deliveryMode = 0; /** @type {string} */ errMessage = ""; /** @type {integer} */ #currentProcess = 0; /** @type {boolean} */ #nntpProcessed = false; /** @type {boolean} */ #alreadyDisplayed = false; get currentProcess() { return this.#currentProcess; } set currentProcess(value) { if (value < 0 || value > Ci.nsIMsgSendReport.process_FCC) { throw new Error(`Illegal process value: ${value}`); } this.#currentProcess = value; if (value == Ci.nsIMsgSendReport.process_NNTP) { this.#nntpProcessed = true; } } reset() { this.deliveryMode = 0; this.errMessage = ""; this.#currentProcess = 0; this.#nntpProcessed = false; this.#alreadyDisplayed = false; } /** * Display Report will analyze data collected during the send and will show * the most appropriate error. * * @param {?mozIDOMWindowProxy} win */ displayReport(win) { if (this.#alreadyDisplayed) { return; } this.#alreadyDisplayed = true; const composeBundle = Services.strings.createBundle( "chrome://messenger/locale/messengercompose/composeMsgs.properties" ); if ( this.deliveryMode == Ci.nsIMsgCompDeliverMode.Now || this.deliveryMode == Ci.nsIMsgCompDeliverMode.SendUnsent ) { const title = composeBundle.GetStringFromName("sendMessageErrorTitle"); let str = "sendFailed"; switch (this.#currentProcess) { case Ci.nsIMsgSendReport.process_SMTP: str = this.#nntpProcessed ? "sendFailedButNntpOk" : "sendFailed"; break; case Ci.nsIMsgSendReport.process_Copy: case Ci.nsIMsgSendReport.process_FCC: str = "failedCopyOperation"; break; } let message = composeBundle.GetStringFromName(str); if (this.errMessage) { message += "\n" + this.errMessage; } Services.prompt.alert(win, title, message); } else { let titleStr = "sendMessageErrorTitle"; let str = "sendFailed"; switch (this.deliveryMode) { case Ci.nsIMsgCompDeliverMode.Later: titleStr = "sendLaterErrorTitle"; str = "unableToSendLater"; break; case Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft: case Ci.nsIMsgCompDeliverMode.SaveAsDraft: titleStr = "saveDraftErrorTitle"; str = "unableToSaveDraft"; break; case Ci.nsIMsgCompDeliverMode.SaveAsTemplate: titleStr = "saveTemplateErrorTitle"; str = "unableToSaveTemplate"; break; } const title = composeBundle.GetStringFromName(titleStr); let message = composeBundle.GetStringFromName(str); if (this.errMessage) { message += "\n" + this.errMessage; } Services.prompt.alert(win, title, message); } } }