diff options
Diffstat (limited to 'comm/mail/modules/AttachmentInfo.sys.mjs')
-rw-r--r-- | comm/mail/modules/AttachmentInfo.sys.mjs | 626 |
1 files changed, 626 insertions, 0 deletions
diff --git a/comm/mail/modules/AttachmentInfo.sys.mjs b/comm/mail/modules/AttachmentInfo.sys.mjs new file mode 100644 index 0000000000..8d7fa7920b --- /dev/null +++ b/comm/mail/modules/AttachmentInfo.sys.mjs @@ -0,0 +1,626 @@ +/* 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", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gHandlerService: [ + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService", + ], + gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"], +}); + +XPCOMUtils.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 { + let 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; + } + + let 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; + } + + let win = browsingContext.topChromeWindow; + let empty = await this.isEmpty(); + if (empty) { + let prompt = lazy.messengerBundle.GetStringFromName( + this.isExternalAttachment + ? "externalAttachmentNotFound" + : "emptyAttachment" + ); + Services.prompt.alert(win, null, prompt); + } else { + // @see MsgComposeCommands.js which has simililar opening functionality + let dotPos = this.name.lastIndexOf("."); + let extension = + dotPos >= 0 ? this.name.substring(dotPos + 1).toLowerCase() : ""; + if (this.contentType == "application/pdf" || extension == "pdf") { + let 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. + let 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; + + let sourceURI = Services.io.newURI(url); + async function saveToFile(path, isTmp = false) { + let 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. + let file = await IOUtils.getFile(path); + lazy.Downloads.createDownload({ + source: { + url: sourceURI.spec, + }, + target: file, + startTime: new Date(), + }) + .then(async download => { + await download.start(); + let 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); + + let createTemporaryFileAndOpen = async mimeInfo => { + let tmpPath = PathUtils.join( + Services.dirsvc.get("TmpD", Ci.nsIFile).path, + "pid-" + Services.appinfo.processID + ); + await IOUtils.makeDirectory(tmpPath, { permissions: 0o700 }); + let 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); + }; + + let openLocalFile = mimeInfo => { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + + try { + let 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")) { + let 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 { + let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + filePicker.defaultString = this.name; + filePicker.defaultExtension = extension; + filePicker.init( + win, + lazy.messengerBundle.GetStringFromName("SaveAttachment"), + Ci.nsIFilePicker.modeSave + ); + let 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. + let 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(reason) {}, + promptForSaveDestination() { + appLauncherDialog.promptForSaveToFileAsync( + this, + win, + this.suggestedFileName, + "." + extension, // Dot stripped by promptForSaveToFileAsync. + false + ); + }, + launchLocalFile() { + openLocalFile(mimeInfo); + }, + async setDownloadToLaunch(handleInternally, file) { + await createTemporaryFileAndOpen(mimeInfo); + }, + async saveDestinationAvailable(file) { + if (file) { + await saveToFile(file.path); + } + }, + setWebProgressListener(webProgressListener) {}, + 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 => { + let 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; + let options = { method: "GET" }; + + let 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. + let reader = response.body.getReader(); + let 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. + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + try { + fileHandler.getFileFromURLSpec(this.displayUrl).reveal(); + } catch (ex) { + console.error( + "AttachmentInfo.openFolder: file - " + this.displayUrl + ", " + ex + ); + } + } +} |