Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.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 { jsmime } from "resource:///modules/jsmime.sys.mjs";
import { MimeEncoder } from "resource:///modules/MimeEncoder.sys.mjs";
import { MsgUtils } from "resource:///modules/MimeMessageUtils.sys.mjs";
import { MailServices } from "resource:///modules/MailServices.sys.mjs";
import { MailStringUtils } from "resource:///modules/MailStringUtils.sys.mjs";
import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
/**
* A class to represent a RFC2045 message. MimePart can be nested, each MimePart
* can contain a list of MimePart. HTML and plain text are parts as well. Use
* class MimeMultiPart for multipart/*, that's why this class doesn't expose an
* addPart method
*/
export class MimePart {
/**
* @param {string} contentType - Content type of the part, e.g. text/plain.
* @param {boolean} forceMsgEncoding - A flag used to determine Content-Transfer-Encoding.
* @param {boolean} isMainBody - The part is main part or an attachment part.
*/
constructor(contentType = "", forceMsgEncoding = false, isMainBody = false) {
this._charset = "UTF-8";
this._contentType = contentType;
this._forceMsgEncoding = forceMsgEncoding;
this._isMainBody = isMainBody;
this._headers = new Map();
// 8-bit string to avoid converting back and forth.
this._bodyText = "";
this._bodyAttachment = null;
this._contentDisposition = null;
this._contentId = null;
this._separator = "";
this._parts = [];
}
/**
* @type {BinaryString} text - The string to use as body.
*/
set bodyText(text) {
this._bodyText = text.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n");
}
/**
* @type {MimePart[]} - The child parts.
*/
get parts() {
return this._parts;
}
/**
* @type {MimePart[]} parts - The child parts.
*/
set parts(parts) {
this._parts = parts;
}
/**
* @type {string} - The separator string.
*/
get separator() {
return this._separator;
}
/**
* Set a header.
*
* @param {string} name - The header name, e.g. "content-type".
* @param {string} content - The header content, e.g. "text/plain".
*/
setHeader(name, content) {
if (!content) {
return;
}
// There is no Content-Type encoder in jsmime yet. If content is not string,
// assume it's already a structured header.
if (name == "content-type" || typeof content != "string") {
// _headers will be passed to jsmime, which requires header content to be
// an array.
this._headers.set(name, [content]);
return;
}
try {
this._headers.set(name, [
jsmime.headerparser.parseStructuredHeader(name, content),
]);
} catch (e) {
this._headers.set(name, [content.trim()]);
}
}
/**
* Delete a header.
*
* @param {string} name - The header name to delete, e.g. "content-type".
*/
deleteHeader(name) {
this._headers.delete(name);
}
/**
* Set headers by an iterable.
*
* @param {Iterable.<string, string>} entries - The header entries.
*/
setHeaders(entries) {
for (const [name, content] of entries) {
this.setHeader(name, content);
}
}
/**
* Set an attachment as body, with optional contentDisposition and contentId.
*
* @param {nsIMsgAttachment} attachment - The attachment to use as body.
* @param {string} [contentDisposition=attachment] - "attachment" or "inline".
* @param {string} [contentId] - The url of an embedded object is cid:contentId.
*/
setBodyAttachment(
attachment,
contentDisposition = "attachment",
contentId = null
) {
this._bodyAttachment = attachment;
this._contentDisposition = contentDisposition;
this._contentId = contentId;
}
/**
* Add a child part.
*
* @param {MimePart} part - A MimePart.
*/
addPart(part) {
this._parts.push(part);
}
/**
* Add child parts.
*
* @param {MimePart[]} parts - An array of MimePart.
*/
addParts(parts) {
this._parts.push(...parts);
}
/**
* Pick an encoding according to _bodyText or _bodyAttachment content. Set
* content-transfer-encoding header, then return the encoded value.
*
* @returns {BinaryString}
*/
async getEncodedBodyString() {
let bodyString = this._bodyText;
// If this is an attachment part, use the attachment content as bodyString.
if (this._bodyAttachment) {
try {
bodyString = await this._fetchAttachment();
} catch (e) {
MsgUtils.sendLogger.error(
`Failed to fetch attachment; name=${this._bodyAttachment.name}, url=${this._bodyAttachment.url}, error=${e}`
);
throw Components.Exception(
"Failed to fetch attachment",
MsgUtils.NS_MSG_ERROR_ATTACHING_FILE,
e.stack,
this._bodyAttachment
);
}
}
if (bodyString) {
const encoder = new MimeEncoder(
this._charset,
this._contentType,
this._forceMsgEncoding,
this._isMainBody,
bodyString
);
encoder.pickEncoding();
this.setHeader("content-transfer-encoding", encoder.encoding);
bodyString = encoder.encode();
} else if (this._isMainBody) {
this.setHeader("content-transfer-encoding", "7bit");
}
return bodyString;
}
/**
* Use jsmime to convert _headers to string.
*
* @returns {string}
*/
getHeaderString() {
return jsmime.headeremitter.emitStructuredHeaders(this._headers, {
useASCII: true,
sanitizeDate: Services.prefs.getBoolPref(
"mail.sanitize_date_header",
false
),
});
}
/**
* Fetch the attached message file to get its content.
*
* @returns {string}
*/
async _fetchMsgAttachment() {
const msgService = MailServices.messageServiceFromURI(
this._bodyAttachment.url
);
return new Promise((resolve, reject) => {
const streamListener = {
_data: "",
_stream: null,
onDataAvailable(request, inputStream, offset, count) {
if (!this._stream) {
this._stream = Cc[
"@mozilla.org/scriptableinputstream;1"
].createInstance(Ci.nsIScriptableInputStream);
this._stream.init(inputStream);
}
this._data += this._stream.read(count);
},
onStartRequest() {},
onStopRequest(request, status) {
if (Components.isSuccessCode(status)) {
resolve(this._data);
} else {
reject(`Fetch message attachment failed with status=${status}`);
}
},
QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
};
msgService.streamMessage(
this._bodyAttachment.url,
streamListener,
null, // msgWindow
null, // urlListener
false, // convertData
"" // additionalHeader
);
});
}
/**
* Fetch the attachment file to get its content type and content.
*
* Previously, we used the Fetch API to download all attachments, but Fetch
* doesn't support url with embedded credentials (imap://name@server). As a
* result, it's unreliable when having two mail accounts on the same IMAP
* server.
*
* @returns {string}
*/
async _fetchAttachment() {
const url = this._bodyAttachment.url;
MsgUtils.sendLogger.debug(`Fetching ${url}`);
let content = "";
if (/^[^:]+-message:/i.test(url)) {
content = await this._fetchMsgAttachment();
if (!content) {
// Message content is empty usually means it's (re)moved.
throw new Error("Message is gone");
}
this._contentType = "message/rfc822";
} else {
const channel = Services.io.newChannelFromURI(
Services.io.newURI(url),
null,
Services.scriptSecurityManager.getSystemPrincipal(),
null,
Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
Ci.nsIContentPolicy.TYPE_OTHER
);
content = await new Promise((resolve, reject) =>
NetUtil.asyncFetch(channel, (stream, status) => {
if (!Components.isSuccessCode(status)) {
reject(`asyncFetch failed with status=${status}`);
return;
}
let data = "";
try {
data = NetUtil.readInputStreamToString(stream, stream.available());
} catch (e) {
// stream.available() throws if the file is empty.
}
resolve(data);
})
);
this._contentType =
this._bodyAttachment.contentType || channel.contentType;
}
const parmFolding = Services.prefs.getIntPref(
"mail.strictly_mime.parm_folding",
2
);
// File name can contain non-ASCII chars, encode according to RFC 2231.
let encodedName, encodedFileName;
if (this._bodyAttachment.name) {
encodedName = MsgUtils.rfc2047EncodeParam(this._bodyAttachment.name);
encodedFileName = MsgUtils.rfc2231ParamFolding(
"filename",
this._bodyAttachment.name
);
}
this._charset = this._contentType.startsWith("text/")
? MailStringUtils.detectCharset(content)
: "";
let contentTypeParams = "";
if (this._charset) {
contentTypeParams += `; charset=${this._charset}`;
}
if (encodedName && parmFolding != 2) {
contentTypeParams += `; name="${encodedName}"`;
}
this.setHeader("content-type", `${this._contentType}${contentTypeParams}`);
if (encodedFileName) {
this.setHeader(
"content-disposition",
`${this._contentDisposition}; ${encodedFileName}`
);
}
if (this._contentId) {
this.setHeader("content-id", `<${this._contentId}>`);
}
if (this._contentType == "text/html") {
const contentLocation = MsgUtils.getContentLocation(
this._bodyAttachment.url
);
this.setHeader("content-location", contentLocation);
} else if (this._contentType == "application/pgp-keys") {
this.setHeader("content-description", "OpenPGP public key");
}
return content;
}
}
/**
* A class to represent a multipart/* part inside a RFC2045 message.
*/
export class MimeMultiPart extends MimePart {
/**
* @param {string} subtype - The multipart subtype, e.g. "alternative" or "mixed".
*/
constructor(subtype) {
super();
this.subtype = subtype;
this._separator = this._makePartSeparator();
this.setHeader(
"content-type",
`multipart/${subtype}; boundary="${this._separator}"`
);
}
/**
* Use 12 hyphen characters and 24 random base64 characters as separator.
*/
_makePartSeparator() {
return "------------" + MsgUtils.randomString(24);
}
}