releases-comm-central/mail/modules/AttachmentInfo.sys.mjs

624 строки
20 KiB
JavaScript

/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
Downloads: "resource://gre/modules/Downloads.sys.mjs",
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
GlodaUtils: "resource:///modules/gloda/GlodaUtils.sys.mjs",
MailUtils: "resource:///modules/MailUtils.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetters(lazy, {
gHandlerService: [
"@mozilla.org/uriloader/handler-service;1",
"nsIHandlerService",
],
gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"],
});
ChromeUtils.defineLazyGetter(lazy, "messengerBundle", () => {
return Services.strings.createBundle(
"chrome://messenger/locale/messenger.properties"
);
});
/**
* A class to handle attachment information and actions.
*/
export class AttachmentInfo {
/**
* A cache of message/rfc822 attachments saved to temporary files for display.
* Saving the same attachment again is avoided.
*
* @type {Map<string, nsIFile>}
*/
#temporaryFiles = new Map();
/**
* A function to call when checking to see if an attachment exists or not, so
* that the display can be updated.
*
* @type {Function}
*/
#updateAttachmentsDisplayFn = null;
/**
* Create a new attachment object which goes into the data attachment array.
* This method checks whether the passed attachment is empty or not.
*
* @param {object} options
* @param {string} options.contentType - The attachment's mimetype.
* @param {string} options.url - The URL for the attachment.
* @param {string} options.name - The name to be displayed for this attachment
* (usually the filename).
* @param {string} options.uri - The URI for the message containing the
* attachment.
* @param {boolean} options.isExternalAttachment - True if the attachment has
* been detached to file or is a link attachment.
* @param {object} options.message - The message object associated to this
* attachment.
* @param {Function} [updateAttachmentsDisplayFn] - An optional callback
* function that is called to update the attachment display at appropriate
* times.
*/
constructor({
contentType,
url,
name,
uri,
isExternalAttachment,
message,
updateAttachmentsDisplayFn,
}) {
this.message = message;
this.contentType = contentType;
this.name = name;
this.url = url;
this.uri = uri;
this.isExternalAttachment = isExternalAttachment;
this.#updateAttachmentsDisplayFn = updateAttachmentsDisplayFn;
// A |size| value of -1 means we don't have a valid size. Check again if
// |sizeResolved| is false. For internal attachments and link attachments
// with a reported size, libmime streams values to addAttachmentField()
// which updates this object. For external file attachments, |size| is updated
// in the #() function when the list is built. Deleted attachments
// are resolved to -1.
this.size = -1;
this.sizeResolved = this.isDeleted;
// Remove [?&]part= from remote urls, after getting the partID.
// Remote urls, unlike non external mail part urls, may also contain query
// strings starting with ?; PART_RE does not handle this.
if (this.isLinkAttachment || this.isFileAttachment) {
let match = url.match(/[?&]part=[^&]+$/);
match = match && match[0];
this.partID = match && match.split("part=")[1];
this.url = url.replace(match, "");
} else {
const match = lazy.GlodaUtils.PART_RE.exec(url);
this.partID = match && match[1];
}
}
/**
* Save this attachment to a file.
*
* @param {nsIMessenger} messenger
* The messenger object associated with the window.
*/
async save(messenger) {
if (!this.hasFile) {
return;
}
const empty = await this.isEmpty();
if (empty) {
return;
}
messenger.saveAttachment(
this.contentType,
this.url,
encodeURIComponent(this.name),
this.uri,
this.isExternalAttachment
);
}
/**
* Open this attachment.
*
* @param {integer} [browsingContextId]
* The browsingContext of the browser that this attachment is being opened
* from.
*/
async open(browsingContext) {
if (!this.hasFile) {
return;
}
const win = browsingContext.topChromeWindow;
const empty = await this.isEmpty();
if (empty) {
const prompt = lazy.messengerBundle.GetStringFromName(
this.isExternalAttachment
? "externalAttachmentNotFound"
: "emptyAttachment"
);
Services.prompt.alert(win, null, prompt);
} else {
// @see MsgComposeCommands.js which has simililar opening functionality
const dotPos = this.name.lastIndexOf(".");
const extension =
dotPos >= 0 ? this.name.substring(dotPos + 1).toLowerCase() : "";
if (this.contentType == "application/pdf" || extension == "pdf") {
const handlerInfo = lazy.gMIMEService.getFromTypeAndExtension(
this.contentType,
extension
);
// Only open a new tab for pdfs if we are handling them internally.
if (
!handlerInfo.alwaysAskBeforeHandling &&
handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally
) {
// Add the content type to avoid a "how do you want to open this?"
// dialog. The type may already be there, but that doesn't matter.
let url = this.url;
if (!url.includes("type=")) {
url += url.includes("?") ? "&" : "?";
url += "type=application/pdf";
}
let tabmail = win.document.getElementById("tabmail");
if (!tabmail) {
// If no tabmail available in this window, try and find it in
// another.
const win = Services.wm.getMostRecentWindow("mail:3pane");
tabmail = win?.document.getElementById("tabmail");
}
if (tabmail) {
tabmail.openTab("contentTab", {
url,
background: false,
linkHandler: "single-page",
});
tabmail.ownerGlobal.focus();
return;
}
// If no tabmail, open PDF same as other attachments.
}
}
// Just use the old method for handling messages, it works.
let { name, url } = this;
const sourceURI = Services.io.newURI(url);
async function saveToFile(path, isTmp = false) {
const buffer = await new Promise((resolve, reject) => {
lazy.NetUtil.asyncFetch(
{
uri: sourceURI,
loadUsingSystemPrincipal: true,
},
(inputStream, status) => {
if (Components.isSuccessCode(status)) {
resolve(lazy.NetUtil.readInputStream(inputStream));
} else {
reject(
new Components.Exception(`Failed to fetch ${path}`, status)
);
}
}
);
});
await IOUtils.write(path, new Uint8Array(buffer));
if (!isTmp) {
// Create a download so that saved files show up under... Saved Files.
const file = await IOUtils.getFile(path);
lazy.Downloads.createDownload({
source: {
url: sourceURI.spec,
},
target: file,
startTime: new Date(),
})
.then(async download => {
await download.start();
const list = await lazy.Downloads.getList(lazy.Downloads.ALL);
await list.add(download);
})
.catch(console.error);
}
}
if (this.contentType == "message/rfc822") {
let tempFile = this.#temporaryFiles.get(url);
if (!tempFile?.exists()) {
tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
tempFile.append("subPart.eml");
tempFile.createUnique(0, 0o600);
await saveToFile(tempFile.path, true);
this.#temporaryFiles.set(url, tempFile);
}
lazy.MailUtils.openEMLFile(
win,
tempFile,
Services.io.newFileURI(tempFile)
);
return;
}
// Get the MIME info from the service.
let mimeInfo;
try {
mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
this.contentType,
extension
);
} catch (ex) {
// If the call above fails, which can happen on Windows where there's
// nothing registered for the file type, assume this generic type.
mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
"application/octet-stream",
""
);
}
// The default action is saveToDisk, which is not what we want.
// If we don't have a stored handler, ask before handling.
if (!lazy.gHandlerService.exists(mimeInfo)) {
mimeInfo.alwaysAskBeforeHandling = true;
mimeInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
}
// If we know what to do, do it.
name = lazy.DownloadPaths.sanitize(name);
const createTemporaryFileAndOpen = async mimeInfo => {
const tmpPath = PathUtils.join(
Services.dirsvc.get("TmpD", Ci.nsIFile).path,
"pid-" + Services.appinfo.processID
);
await IOUtils.makeDirectory(tmpPath, { permissions: 0o700 });
const tempFile = Cc["@mozilla.org/file/local;1"].createInstance(
Ci.nsIFile
);
tempFile.initWithPath(tmpPath);
tempFile.append(name);
tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
tempFile.remove(false);
Cc["@mozilla.org/uriloader/external-helper-app-service;1"]
.getService(Ci.nsPIExternalAppLauncher)
.deleteTemporaryFileOnExit(tempFile);
await saveToFile(tempFile.path, true);
// Before opening from the temp dir, make the file read-only so that
// users don't edit and lose their edits...
await IOUtils.setPermissions(tempFile.path, 0o400); // Set read-only
this._openFile(mimeInfo, tempFile);
};
const openLocalFile = mimeInfo => {
const fileHandler = Services.io
.getProtocolHandler("file")
.QueryInterface(Ci.nsIFileProtocolHandler);
try {
const externalFile = fileHandler.getFileFromURLSpec(this.displayUrl);
this._openFile(mimeInfo, externalFile);
} catch (ex) {
console.error(
"AttachmentInfo.open: file - " + this.displayUrl + ", " + ex
);
}
};
if (!mimeInfo.alwaysAskBeforeHandling) {
switch (mimeInfo.preferredAction) {
case Ci.nsIHandlerInfo.saveToDisk:
if (Services.prefs.getBoolPref("browser.download.useDownloadDir")) {
const destFile = new lazy.FileUtils.File(
await lazy.Downloads.getPreferredDownloadsDirectory()
);
destFile.append(name);
destFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
destFile.remove(false);
await saveToFile(destFile.path);
} else {
const filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(
Ci.nsIFilePicker
);
filePicker.defaultString = this.name;
filePicker.defaultExtension = extension;
filePicker.init(
win.browsingContext,
lazy.messengerBundle.GetStringFromName("SaveAttachment"),
Ci.nsIFilePicker.modeSave
);
const rv = await new Promise(resolve => filePicker.open(resolve));
if (rv != Ci.nsIFilePicker.returnCancel) {
await saveToFile(filePicker.file.path);
}
}
return;
case Ci.nsIHandlerInfo.useHelperApp:
case Ci.nsIHandlerInfo.useSystemDefault:
// Attachments can be detached and, if this is the case, opened from
// their location on disk instead of copied to a temporary file.
if (this.isExternalAttachment) {
openLocalFile(mimeInfo);
return;
}
await createTemporaryFileAndOpen(mimeInfo);
return;
}
}
// Ask what to do, then do it.
const appLauncherDialog = Cc[
"@mozilla.org/helperapplauncherdialog;1"
].createInstance(Ci.nsIHelperAppLauncherDialog);
appLauncherDialog.show(
{
QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncher"]),
MIMEInfo: mimeInfo,
source: Services.io.newURI(this.url),
suggestedFileName: this.name,
cancel() {},
promptForSaveDestination() {
appLauncherDialog.promptForSaveToFileAsync(
this,
win,
this.suggestedFileName,
"." + extension, // Dot stripped by promptForSaveToFileAsync.
false
);
},
launchLocalFile() {
openLocalFile(mimeInfo);
},
async setDownloadToLaunch() {
await createTemporaryFileAndOpen(mimeInfo);
},
async saveDestinationAvailable(file) {
if (file) {
await saveToFile(file.path);
}
},
setWebProgressListener() {},
targetFile: null,
targetFileIsExecutable: null,
timeDownloadStarted: null,
contentLength: this.size,
browsingContextId: browsingContext.id,
},
win,
null
);
}
}
/**
* Unless overridden by a test, opens a saved attachment when called by `open`.
*
* @param {nsIMIMEInfo} mimeInfo
* @param {nsIFile} file
*/
_openFile(mimeInfo, file) {
mimeInfo.launchWithFile(file);
}
/**
* Detach this attachment from the message.
*
* @param {nsIMessenger} messenger
* The messenger object associated with the window.
* @param {boolean} aSaveFirst - true if the attachment should be saved
* before detaching, false otherwise.
*/
detach(messenger, aSaveFirst) {
messenger.detachAttachment(
this.contentType,
this.url,
encodeURIComponent(this.name),
this.uri,
aSaveFirst
);
}
/**
* This method checks whether the attachment has been deleted or not.
*
* @returns true if the attachment has been deleted, false otherwise.
*/
get isDeleted() {
return this.contentType == "text/x-moz-deleted";
}
/**
* This method checks whether the attachment is a detached file.
*
* @returns true if the attachment is a detached file, false otherwise.
*/
get isFileAttachment() {
return this.isExternalAttachment && this.url.startsWith("file:");
}
/**
* This method checks whether the attachment is an http link.
*
* @returns true if the attachment is an http link, false otherwise.
*/
get isLinkAttachment() {
return this.isExternalAttachment && /^https?:/.test(this.url);
}
/**
* This method checks whether the attachment has an associated file or not.
* Deleted attachments or detached attachments with missing external files
* do *not* have a file.
*
* @returns true if the attachment has an associated file, false otherwise.
*/
get hasFile() {
if (this.sizeResolved && this.size == -1) {
return false;
}
return true;
}
/**
* Return display url, decoded and converted to utf8 from IDN punycode ascii,
* if the attachment is external (http or file schemes).
*
* @returns {string} url.
*/
get displayUrl() {
if (this.isExternalAttachment) {
// For status bar url display purposes, we want the displaySpec.
// The ?part= has already been removed.
return decodeURI(Services.io.newURI(this.url).displaySpec);
}
return this.url;
}
/**
* This method checks whether the attachment url location exists and
* is accessible. For http and file urls, fetch() will have the size
* in the content-length header.
*
* @returns {boolean}
* true if the attachment is empty or error, false otherwise.
*/
async isEmpty() {
if (this.isDeleted) {
return true;
}
const isFetchable = url => {
const uri = Services.io.newURI(url);
return !(uri.username || uri.userPass);
};
// We have a resolved size.
if (this.sizeResolved) {
return this.size < 1;
}
if (!isFetchable(this.url)) {
return false;
}
let empty = true;
let size = -1;
const options = { method: "GET" };
const request = new Request(this.url, options);
if (this.isExternalAttachment && this.#updateAttachmentsDisplayFn) {
this.#updateAttachmentsDisplayFn(this, true);
}
await fetch(request)
.then(response => {
if (!response.ok) {
console.warn(
"AttachmentInfo.#: fetch response error - " +
response.statusText +
", response.url - " +
response.url
);
return null;
}
if (this.isLinkAttachment) {
if (response.status < 200 || response.status > 304) {
console.warn(
"AttachmentInfo.#: link fetch response status - " +
response.status +
", response.url - " +
response.url
);
return null;
}
}
return response;
})
.then(async response => {
if (this.isExternalAttachment) {
size = response ? response.headers.get("content-length") : -1;
} else {
// Check the attachment again if addAttachmentField() sets a
// libmime -1 return value for size in this object.
// Note: just test for a non zero size, don't need to drain the
// stream. We only get here if the url is fetchable.
// The size for internal attachments is not calculated here but
// will come from libmime.
const reader = response.body.getReader();
const result = await reader.read();
reader.cancel();
size = result && result.value ? result.value.length : -1;
}
if (size > 0) {
empty = false;
}
})
.catch(error => {
console.warn(`AttachmentInfo.#: ${error.message} url - ${this.url}`);
});
this.sizeResolved = true;
if (this.isExternalAttachment) {
// For link attachments, we may have had a published value or -1
// indicating unknown value. We now know the real size, so set it and
// update the ui. For detached file attachments, get the size here
// instead of the old xpcom way.
this.size = size;
this.#updateAttachmentsDisplayFn?.(this, false);
}
return empty;
}
/**
* Open a file attachment's containing folder.
*/
openFolder() {
if (!this.isFileAttachment || !this.hasFile) {
return;
}
// The file url is stored in the attachment info part with unix path and
// needs to be converted to os path for nsIFile.
const fileHandler = Services.io
.getProtocolHandler("file")
.QueryInterface(Ci.nsIFileProtocolHandler);
try {
fileHandler.getFileFromURLSpec(this.displayUrl).reveal();
} catch (ex) {
console.error(
"AttachmentInfo.openFolder: file - " + this.displayUrl + ", " + ex
);
}
}
}