diff options
Diffstat (limited to 'comm/mailnews/compose/src/MimeMessage.jsm')
-rw-r--r-- | comm/mailnews/compose/src/MimeMessage.jsm | 625 |
1 files changed, 625 insertions, 0 deletions
diff --git a/comm/mailnews/compose/src/MimeMessage.jsm b/comm/mailnews/compose/src/MimeMessage.jsm new file mode 100644 index 0000000000..9423e84004 --- /dev/null +++ b/comm/mailnews/compose/src/MimeMessage.jsm @@ -0,0 +1,625 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["MimeMessage"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +let { MimeMultiPart, MimePart } = ChromeUtils.import( + "resource:///modules/MimePart.jsm" +); +let { MsgUtils } = ChromeUtils.import( + "resource:///modules/MimeMessageUtils.jsm" +); +let { jsmime } = ChromeUtils.import("resource:///modules/jsmime.jsm"); + +/** + * A class to create a top MimePart and write to a tmp file. It works like this: + * 1. collect top level MIME headers (_gatherMimeHeaders) + * 2. collect HTML/plain main body as MimePart[] (_gatherMainParts) + * 3. collect attachments as MimePart[] (_gatherAttachmentParts) + * 4. construct a top MimePart with above headers and MimePart[] (_initMimePart) + * 5. write the top MimePart to a tmp file (createMessageFile) + * NOTE: It's possible we will want to replace nsIMsgSend with the interfaces of + * MimeMessage. As a part of it, we will add a `send` method to this class. + */ +class MimeMessage { + /** + * Construct a MimeMessage. + * + * @param {nsIMsgIdentity} userIdentity + * @param {nsIMsgCompFields} compFields + * @param {string} fcc - The FCC header value. + * @param {string} bodyType + * @param {BinaryString} bodyText - This is ensured to be a 8-bit string, to + * be handled the same as attachment content. + * @param {nsMsgDeliverMode} deliverMode + * @param {string} originalMsgURI + * @param {MSG_ComposeType} compType + * @param {nsIMsgAttachment[]} embeddedAttachments - Usually Embedded images. + * @param {nsIMsgSendReport} sendReport - Used by _startCryptoEncapsulation. + */ + constructor( + userIdentity, + compFields, + fcc, + bodyType, + bodyText, + deliverMode, + originalMsgURI, + compType, + embeddedAttachments, + sendReport + ) { + this._userIdentity = userIdentity; + this._compFields = compFields; + this._fcc = fcc; + this._bodyType = bodyType; + this._bodyText = bodyText; + this._deliverMode = deliverMode; + this._compType = compType; + this._embeddedAttachments = embeddedAttachments; + this._sendReport = sendReport; + } + + /** + * Write a MimeMessage to a tmp file. + * + * @returns {nsIFile} + */ + async createMessageFile() { + let topPart = this._initMimePart(); + let file = Services.dirsvc.get("TmpD", Ci.nsIFile); + file.append("nsemail.eml"); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + + let fstream = Cc[ + "@mozilla.org/network/file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + this._fstream = Cc[ + "@mozilla.org/network/buffered-output-stream;1" + ].createInstance(Ci.nsIBufferedOutputStream); + fstream.init(file, -1, -1, 0); + this._fstream.init(fstream, 16 * 1024); + + this._composeSecure = this._getComposeSecure(); + if (this._composeSecure) { + await this._writePart(topPart); + this._composeSecure.finishCryptoEncapsulation(false, this._sendReport); + } else { + await this._writePart(topPart); + } + + this._fstream.close(); + fstream.close(); + + return file; + } + + /** + * Create a top MimePart to represent the full message. + * + * @returns {MimePart} + */ + _initMimePart() { + let { plainPart, htmlPart } = this._gatherMainParts(); + let embeddedParts = this._gatherEmbeddedParts(); + let attachmentParts = this._gatherAttachmentParts(); + + let relatedPart = htmlPart; + if (htmlPart && embeddedParts.length > 0) { + relatedPart = new MimeMultiPart("related"); + relatedPart.addPart(htmlPart); + relatedPart.addParts(embeddedParts); + } + let mainParts = [plainPart, relatedPart].filter(Boolean); + let topPart; + if (attachmentParts.length > 0) { + // Use multipart/mixed as long as there is at least one attachment. + topPart = new MimeMultiPart("mixed"); + if (plainPart && relatedPart) { + // Wrap mainParts inside a multipart/alternative MimePart. + let alternativePart = new MimeMultiPart("alternative"); + alternativePart.addParts(mainParts); + topPart.addPart(alternativePart); + } else { + topPart.addParts(mainParts); + } + topPart.addParts(attachmentParts); + } else { + if (mainParts.length > 1) { + // Mark the topPart as multipart/alternative. + topPart = new MimeMultiPart("alternative"); + } else { + topPart = new MimePart(); + } + topPart.addParts(mainParts); + } + + topPart.setHeaders(this._gatherMimeHeaders()); + + return topPart; + } + + /** + * Collect top level headers like From/To/Subject into a Map. + */ + _gatherMimeHeaders() { + let messageId = this._compFields.messageId; + if ( + !messageId && + (this._compFields.to || + this._compFields.cc || + this._compFields.bcc || + !this._compFields.newsgroups || + this._userIdentity.getBoolAttribute("generate_news_message_id")) + ) { + // Try to use the domain name of the From header to generate the message ID. We + // specifically don't use the nsIMsgIdentity associated with the account, because + // the user might have changed the address in the From header to use a different + // domain, and we don't want to leak the relationship between the domains. + const fromHdr = MailServices.headerParser.parseEncodedHeaderW( + this._compFields.from + ); + const fromAddr = fromHdr[0].email; + + // Extract the host from the address, if any, and generate a message ID from it. + // If we can't get a host for the message ID, let SMTP populate the header. + const atIndex = fromAddr.indexOf("@"); + if (atIndex >= 0) { + messageId = Cc["@mozilla.org/messengercompose/computils;1"] + .createInstance(Ci.nsIMsgCompUtils) + .msgGenerateMessageId( + this._userIdentity, + fromAddr.slice(atIndex + 1) + ); + } + + this._compFields.messageId = messageId; + } + let headers = new Map([ + ["message-id", messageId], + ["date", new Date()], + ["mime-version", "1.0"], + ]); + + if (Services.prefs.getBoolPref("mailnews.headers.sendUserAgent")) { + if (Services.prefs.getBoolPref("mailnews.headers.useMinimalUserAgent")) { + headers.set( + "user-agent", + Services.strings + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandFullName") + ); + } else { + headers.set( + "user-agent", + Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).userAgent + ); + } + } + + for (let headerName of [...this._compFields.headerNames]) { + let headerContent = this._compFields.getRawHeader(headerName); + if (headerContent) { + headers.set(headerName, headerContent); + } + } + let isDraft = [ + Ci.nsIMsgSend.nsMsgQueueForLater, + Ci.nsIMsgSend.nsMsgDeliverBackground, + Ci.nsIMsgSend.nsMsgSaveAsDraft, + Ci.nsIMsgSend.nsMsgSaveAsTemplate, + ].includes(this._deliverMode); + + let undisclosedRecipients = MsgUtils.getUndisclosedRecipients( + this._compFields, + this._deliverMode + ); + if (undisclosedRecipients) { + headers.set("to", undisclosedRecipients); + } + + if (isDraft) { + headers + .set( + "x-mozilla-draft-info", + MsgUtils.getXMozillaDraftInfo(this._compFields) + ) + .set("x-identity-key", this._userIdentity.key) + .set("fcc", this._fcc); + } + + if (messageId) { + // MDN request header requires to have MessageID header presented in the + // message in order to coorelate the MDN reports to the original message. + headers + .set( + "disposition-notification-to", + MsgUtils.getDispositionNotificationTo( + this._compFields, + this._deliverMode + ) + ) + .set( + "return-receipt-to", + MsgUtils.getReturnReceiptTo(this._compFields, this._deliverMode) + ); + } + + for (let { headerName, headerValue } of MsgUtils.getDefaultCustomHeaders( + this._userIdentity + )) { + headers.set(headerName, headerValue); + } + + let rawMftHeader = headers.get("mail-followup-to"); + // If there's already a Mail-Followup-To header, don't need to do anything. + if (!rawMftHeader) { + headers.set( + "mail-followup-to", + MsgUtils.getMailFollowupToHeader(this._compFields, this._userIdentity) + ); + } + + let rawMrtHeader = headers.get("mail-reply-to"); + // If there's already a Mail-Reply-To header, don't need to do anything. + if (!rawMrtHeader) { + headers.set( + "mail-reply-to", + MsgUtils.getMailReplyToHeader( + this._compFields, + this._userIdentity, + rawMrtHeader + ) + ); + } + + let rawPriority = headers.get("x-priority"); + if (rawPriority) { + headers.set("x-priority", MsgUtils.getXPriority(rawPriority)); + } + + let rawReferences = headers.get("references"); + if (rawReferences) { + let references = MsgUtils.getReferences(rawReferences); + // Don't reset "references" header if references is undefined. + if (references) { + headers.set("references", references); + } + headers.set("in-reply-to", MsgUtils.getInReplyTo(rawReferences)); + } + if ( + rawReferences && + [ + Ci.nsIMsgCompType.ForwardInline, + Ci.nsIMsgCompType.ForwardAsAttachment, + ].includes(this._compType) + ) { + headers.set("x-forwarded-message-id", rawReferences); + } + + let rawNewsgroups = headers.get("newsgroups"); + if (rawNewsgroups) { + let { newsgroups, newshost } = MsgUtils.getNewsgroups( + this._deliverMode, + rawNewsgroups + ); + // Don't reset "newsgroups" header if newsgroups is undefined. + if (newsgroups) { + headers.set("newsgroups", newsgroups); + } + headers.set("x-mozilla-news-host", newshost); + } + + return headers; + } + + /** + * Determine if the message should include an HTML part, a plain part or both. + * + * @returns {{plainPart: MimePart, htmlPart: MimePart}} + */ + _gatherMainParts() { + let formatFlowed = Services.prefs.getBoolPref( + "mailnews.send_plaintext_flowed" + ); + let formatParam = ""; + if (formatFlowed) { + // Set format=flowed as in RFC 2646 according to the preference. + formatParam += "; format=flowed"; + } + + let htmlPart = null; + let plainPart = null; + let parts = {}; + + if (this._bodyType === "text/html") { + htmlPart = new MimePart( + this._bodyType, + this._compFields.forceMsgEncoding, + true + ); + htmlPart.setHeader("content-type", "text/html; charset=UTF-8"); + htmlPart.bodyText = this._bodyText; + } else if (this._bodyType === "text/plain") { + plainPart = new MimePart( + this._bodyType, + this._compFields.forceMsgEncoding, + true + ); + plainPart.setHeader( + "content-type", + `text/plain; charset=UTF-8${formatParam}` + ); + plainPart.bodyText = this._bodyText; + parts.plainPart = plainPart; + } + + // Assemble a multipart/alternative message. + if ( + (this._compFields.forcePlainText || + this._compFields.useMultipartAlternative) && + plainPart === null && + htmlPart !== null + ) { + plainPart = new MimePart( + "text/plain", + this._compFields.forceMsgEncoding, + true + ); + plainPart.setHeader( + "content-type", + `text/plain; charset=UTF-8${formatParam}` + ); + // nsIParserUtils.convertToPlainText expects unicode string. + let plainUnicode = MsgUtils.convertToPlainText( + new TextDecoder().decode( + jsmime.mimeutils.stringToTypedArray(this._bodyText) + ), + formatFlowed + ); + // MimePart.bodyText should be binary string. + plainPart.bodyText = jsmime.mimeutils.typedArrayToString( + new TextEncoder().encode(plainUnicode) + ); + + parts.plainPart = plainPart; + } + + // If useMultipartAlternative is true, send multipart/alternative message. + // Otherwise, send the plainPart only. + if (htmlPart) { + if ( + (plainPart && this._compFields.useMultipartAlternative) || + !plainPart + ) { + parts.htmlPart = htmlPart; + } + } + + return parts; + } + + /** + * Collect local attachments. + * + * @returns {MimePart[]} + */ + _gatherAttachmentParts() { + let attachments = [...this._compFields.attachments]; + let cloudParts = []; + let localParts = []; + + for (let attachment of attachments) { + let part; + if (attachment.htmlAnnotation) { + part = new MimePart(); + // MimePart.bodyText should be binary string. + part.bodyText = jsmime.mimeutils.typedArrayToString( + new TextEncoder().encode(attachment.htmlAnnotation) + ); + part.setHeader("content-type", "text/html; charset=utf-8"); + + let suffix = /\.html$/i.test(attachment.name) ? "" : ".html"; + let encodedFilename = MsgUtils.rfc2231ParamFolding( + "filename", + `${attachment.name}${suffix}` + ); + part.setHeader("content-disposition", `attachment; ${encodedFilename}`); + } else { + part = new MimePart(null, this._compFields.forceMsgEncoding, false); + part.setBodyAttachment(attachment); + } + + let cloudPartHeader = MsgUtils.getXMozillaCloudPart( + this._deliverMode, + attachment + ); + if (cloudPartHeader) { + part.setHeader("x-mozilla-cloud-part", cloudPartHeader); + } + + localParts.push(part); + } + // Cloud attachments are handled before local attachments in the C++ + // implementation. We follow it here so that no need to change tests. + return cloudParts.concat(localParts); + } + + /** + * Collect embedded objects as attachments. + * + * @returns {MimePart[]} + */ + _gatherEmbeddedParts() { + return this._embeddedAttachments.map(attachment => { + let part = new MimePart(null, this._compFields.forceMsgEncoding, false); + part.setBodyAttachment(attachment, "inline", attachment.contentId); + return part; + }); + } + + /** + * If crypto encapsulation is required, returns an nsIMsgComposeSecure instance. + * + * @returns {nsIMsgComposeSecure} + */ + _getComposeSecure() { + let secureCompose = this._compFields.composeSecure; + if (!secureCompose) { + return null; + } + + if ( + this._deliverMode == Ci.nsIMsgSend.nsMsgSaveAsDraft && + !this._userIdentity.getBoolAttribute("autoEncryptDrafts") + ) { + return null; + } + + if ( + !secureCompose.requiresCryptoEncapsulation( + this._userIdentity, + this._compFields + ) + ) { + return null; + } + return secureCompose; + } + + /** + * Pass a stream and other params to this._composeSecure to start crypto + * encapsulation. + */ + _startCryptoEncapsulation() { + let recipients = [ + this._compFields.to, + this._compFields.cc, + this._compFields.bcc, + this._compFields.newsgroups, + ] + .filter(Boolean) + .join(","); + + this._composeSecure.beginCryptoEncapsulation( + this._fstream, + recipients, + this._compFields, + this._userIdentity, + this._sendReport, + this._deliverMode == Ci.nsIMsgSend.nsMsgSaveAsDraft + ); + this._cryptoEncapsulationStarted = true; + } + + /** + * Recursively write an MimePart and its parts to a this._fstream. + * + * @param {MimePart} curPart - The MimePart to write out. + * @param {number} [depth=0] - Nested level of a part. + */ + async _writePart(curPart, depth = 0) { + let bodyString; + try { + // `getEncodedBodyString()` returns a binary string. + bodyString = await curPart.getEncodedBodyString(); + } catch (e) { + if (e.data && /^data:/i.test(e.data.url)) { + // Invalid data uri should not prevent sending message. + return; + } + throw e; + } + + if (depth == 0 && this._composeSecure) { + // Crypto encapsulation will add a new content-type header. + curPart.deleteHeader("content-type"); + if (curPart.parts.length > 1) { + // Move child parts one layer deeper so that the message is still well + // formed after crypto encapsulation. + let newChild = new MimeMultiPart(curPart.subtype); + newChild.parts = curPart._parts; + curPart.parts = [newChild]; + } + } + + // Write out headers, there could be non-ASCII in the headers + // which we need to encode into UTF-8. + this._writeString(curPart.getHeaderString()); + + // Start crypto encapsulation if needed. + if (depth == 0 && this._composeSecure) { + this._startCryptoEncapsulation(); + } + + // Recursively write out parts. + if (curPart.parts.length) { + // single part message + if (curPart.parts.length === 1) { + await this._writePart(curPart.parts[0], depth + 1); + this._writeBinaryString(bodyString); + return; + } + + // We can safely use `_writeBinaryString()` for ASCII strings. + this._writeBinaryString("\r\n"); + if (depth == 0) { + // Current part is a top part and multipart container. + this._writeBinaryString( + "This is a multi-part message in MIME format.\r\n" + ); + } + + // multipart message + for (let part of curPart.parts) { + this._writeBinaryString(`--${curPart.separator}\r\n`); + await this._writePart(part, depth + 1); + } + this._writeBinaryString(`\r\n--${curPart.separator}--\r\n`); + if (depth > 1) { + // If more separators follow, make sure there is a blank line after + // this one. + this._writeBinaryString("\r\n"); + } + } else { + this._writeBinaryString(`\r\n`); + } + + // Ensure there is exactly one blank line after a part and before + // the boundary, and exactly one blank line between boundary lines. + // This works around bugs in other software that erroneously remove + // additional blank lines, thereby causing verification failures of + // OpenPGP or S/MIME signatures. For example see bug 1731529. + + // Write out body. + this._writeBinaryString(bodyString); + } + + /** + * Write a binary string to this._fstream. + * + * @param {BinaryString} str - The binary string to write. + */ + _writeBinaryString(str) { + this._cryptoEncapsulationStarted + ? this._composeSecure.mimeCryptoWriteBlock(str, str.length) + : this._fstream.write(str, str.length); + } + + /** + * Write a string to this._fstream. + * + * @param {string} str - The string to write. + */ + _writeString(str) { + this._writeBinaryString( + jsmime.mimeutils.typedArrayToString(new TextEncoder().encode(str)) + ); + } +} |