mail-archive/www/index.js
2020-07-20 19:49:29 -05:00

780 lines
28 KiB
JavaScript

// Converts bytes to a human readable value.
function bytesToHuman(bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024)),
sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + sizes[i];
}
// A timer for when messages are shown on screen to auto hide.
var messageTimer = null;
// Hide the message on screen.
function hideMessage() {
// Get the message div.
var message = document.getElementById("message");
message.style.display = "none"; // Do not display.
// Clear the message timer.
clearTimeout(messageTimer);
messageTimer = null;
}
// Display a standard message on screen.
function displayMessage(message) {
displayMessage(message, "cadetblue", true);
}
// Display a sucessful message with green color.
function displaySuccess(message) {
displayMessage(message, "green", true);
}
// Display an error message with red color.
function displayError(message) {
displayMessage(message, "red", true);
}
// Display a message with a timeout and color specified.
function displayMessage(message, color, timeout) {
// If no color defined, we use cadetblue.
if (color == undefined) {
color = "cadetblue";
}
// Log the message to the javascript console.
console.log(message);
// Get the message div.
var messageDiv = document.getElementById("message");
messageDiv.innerText = message;
messageDiv.style.backgroundColor = color;
messageDiv.style.display = "block"; // Make message visable.
// If a message timer already exists, we can clear the timeout to prevent it from hiding this message.
if (messageTimer!=null) {
clearTimeout(messageTimer);
}
// If message is to timeout, set a timeout to hide in 5 seconds.
if (timeout) {
messageTimer = setTimeout(hideMessage, 5000);
}
}
// Configuration Options
var UIDisableSpamReporting = false;
var UIDisableLogs = false;
// The width calculated for the subject.
var UISubjectWidth = 0;
// Build custom CSS based on configuration.
function rebuildCustomCSS() {
var cssConfig = '<style type="text/css">';
if (UIDisableSpamReporting) {
cssConfig += `
#mailLearnHamButton {
display: none;
}
#mailLearnSpamButton {
display: none;
}
#mailDownloadButton {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
`;
}
if (UIDisableLogs) {
cssConfig += `
.nav-link.log {
display: none;
}
#message_list th.status, #message_list td.status {
display: none;
}
`;
}
if (UISubjectWidth!=0) {
cssConfig += `
#message_list th.subject, #message_list td.subject {
width: ${UISubjectWidth}px;
max-width: ${UISubjectWidth}px;
}
`;
}
cssConfig += "</style>";
// Add style tag to page.
document.getElementById("customcss").innerHTML = cssConfig;
}
// Load configuration from API.
function loadConfig() {
// Call the API.
$.ajax({
dataType: "json",
type: "GET",
url: "/api/config"
})
.done(function(data) {
// If an error ocurred. Display it.
if (data.status=="error") {
displayError("Unable to load configuration: "+data.error);
return;
}
// Save configuration.
UIDisableSpamReporting = data.disable_spam_reporting;
UIDisableLogs = data.disable_logs;
if (data.custom_brand!="") {
$("#navbar_brand").text(data.custom_brand);
}
$("#message_count").text(data.message_count.toLocaleString());
// Rebuild CSS with new config.
rebuildCustomCSS();
})
.fail(function(jqXHR, textStatus) {
// On error, display a message.
displayError("Unable to load configuration: "+textStatus);
});
}
// Storage of the currently selected email message.
var selectedMessage = null;
// Auto resize global resize based variable.
// This variable is basically the current top offset of the message contents view, minus the message list height.
// This allows us to easily determine the max height of the message contents view by taking the window height
// and substracting the message height and this base height.
var messageResizeBase = 0;
// Handle a window resize event.
function handleResize() {
// Get the current message list height from either the message list container itself, or storage.
var messagesH = $("#message_list_container").height();
if (localStorage && 'message_list_height' in localStorage) {
messagesH = localStorage.message_list_height;
$("#message_list_container").height(messagesH);
}
// If we don't have a resize base already calculated, calculate it.
if (messageResizeBase==0) {
messageResizeBase = $("#message_contents").offset().top-messagesH;
}
// The new message contents height should be the window height minus messages list height minus the resize base.
var messageH = $(window).height()-messagesH-messageResizeBase;
$("#message_contents").height(messageH);
// Get width of other columns.
var fromWidth = $("th.from").width();
var toWidth = $("th.to").width();
var statusWidth = 0;
if ($("th.status").is(":visible")) {
statusWidth = $("th.status").width();
}
var receivedWidth = $("th.received").width();
// Subtract width of other volumes from windows width.
UISubjectWidth = $(window).width()-(fromWidth+toWidth+statusWidth+receivedWidth+16); // 16 is padding.
// Limit to width of 100 pixels.
if (UISubjectWidth<100) {
UISubjectWidth = 100;
}
// Build the css.
rebuildCustomCSS();
}
// This function will check to see where the active message is in the list, and determine if it is visable.
// If the message is not visable in the message list, it will scroll to make it visable.
function scollToActiveMessageIfNeeded() {
// Get the current selection, and stop if no selection is made.
var selection = $("#message_list .active");
if (selection.length<=0) {
return;
}
// Determine the selection's position in the container list.
var selectionTop = selection.position().top;
var rowH = selection.height();
var container = $("#message_list_container");
// If the message is above the scroll position, we need to scroll up.
if (container.scrollTop()>selectionTop-rowH) {
container.animate({
scrollTop: selectionTop-rowH
}, 200);
} else if (container.scrollTop()+container.height()<selectionTop+rowH) {
// If the message is below the scroll position, we scroll down.
container.animate({
scrollTop: (selectionTop+rowH)-container.height()
}, 200);
}
}
// Global keyboard shortcut handler.
function handleKeydownEvent(e) {
// Variable used to store what should be selected next.
var nextSelection = null;
// Handle keyboard events.
if (e.which==40) {// If key down arrow.
// Check if we have an active message.
var active = $("#message_list .active");
if (active.length==0) { // No active message, select first message in list.
nextSelection = $("#message_list tbody tr").first()
} else { // Active message, get the next entry.
nextSelection = active.next()
}
} else if (e.which==38) { // If key up arrow.
// Check if we have an active message.
var active = $("#message_list .active");
if (active.length==0) { // No active message, select last message in list.
nextSelection = $("#message_list tbody tr").last();
} else { // Active message, get the previous entry.
nextSelection = active.prev();
}
}
// If we have a next selection item, select it.
if (nextSelection!=null && nextSelection.length!=0) {
nextSelection.click();
// Scroll to new selection if needed.
scollToActiveMessageIfNeeded();
// Stop propagating the keyboard event to additional dom objects.
e.preventDefault();
}
}
// This function activates the resizer element as a click/drag type element and adjusts all view sizes accordingly.
function makeMessageListResizable() {
// If we do not currently have a height value stored in the local storage, let's add it.
if (localStorage && !'message_list_height' in localStorage) {
(localStorage.message_list_height = $("#message_list_container").height());
}
// This variable is used to determine if the mouse movements should actually resize the views.
var isResizing = false;
// Where the mousedown event was fired.
var startingPosition = 0;
// What we started with before resizing.
var previousHeight = 0;
// Register for the mousedown and mouseup events in the resizer element.
$("#message_list_resizer")
.mousedown(function(e) {
// On mouse down, we store the starting position and current message list view height.
isResizing = true;
startingPosition = e.pageY;
previousHeight = $("#message_list_container").height();
})
.mouseup(function(e) {
// Now that we are done resizing, we can store the new height.
isResizing = false;
// Calculate new height.
var newHeight = previousHeight+(e.pageY-startingPosition);
// Set new height.
$("#message_list_container").height(newHeight);
// Store new height in local storage.
localStorage && (localStorage.message_list_height = newHeight);
// Adjust the message contents height according to math mentioned in handleResize().
var messageH = $(window).height()-newHeight-messageResizeBase;
$("#message_contents").height(messageH);
});
// Register for the document mouse move event as we will miss some mouse move events if we did so with the resizer element.
$(document).mousemove(function(e) {
// If we're not resizing, we should break here.
if (!isResizing) {
return
}
// Calculate new height of message list.
var newHeight = previousHeight+(e.pageY-startingPosition);
$("#message_list_container").height(newHeight);
// Caculate new height of message contents.
var messageH = $(window).height()-newHeight-messageResizeBase;
$("#message_contents").height(messageH);
});
}
// This variable is set to true if there are new messages to be loaded or if the 1 minute timer is triggered.
// We check this variable every 5 seconds to avoid refreshing too often on heavy email intake.
var shouldRefresh = false;
// During search input, it is possible that text is typed before a response
// from the server from the last query was issued.
// This variable allows us to tell the load messages function to load again after
// it has finnished loading this request.
var shouldSearch = false;
// Keep track as to rather the message list is already loading to prevent concurrent requests.
var loading = false;
// This function loads the list of messages from the API and renders them.
function loadMessageList() {
// If we are already loading, we cannot do concurrent loads.
if (loading) {
return;
}
// Set the fact that we are loading to prevent additional loads.
loading = true;
// Reset all variables as we are loading now.
shouldRefresh = false;
shouldSearch = false;
// Get the search query from the search input.
var query = $("#searchInput").val();
var data = {};
if (query!="") {
data["q"] = query;
}
// Send the request.
$.ajax({
dataType: "json",
type: "GET",
url: "/api/message_log",
data: data
})
.done(function(data) {
// If an error while loading, we can display the error.
if (data.status=="error") {
displayError("Unable to pull messages: "+data.error);
// We are no longer loading.
loading = false;
// If search query was updated during loading, we need to re-load the message list.
if (shouldSearch) {
loadMessageList();
}
return;
}
// Get the message template.
var template = $("#message_list_message_template").html();
// Get the message list table body.
var messageList = $("#message_list tbody");
// Empty the body for new contents.
messageList.html("");
// Read the messages.
for (var i=0; i<data.messages.length; i++) {
var message = data.messages[i];
// Format the received time to a readable format.
message.formatted_date = moment(message.received).format('YYYY-MM-DD HH:mm:ss');
// Add the message encoded with JSON for use on message selection.
message.encoded_message = JSON.stringify(message);
// Render the message and append it to the body.
messageList.append(Mustache.render(template, message));
}
// If a message was selected, try and activate it in the message list if visable.
if (selectedMessage!=null) {
$("#"+selectedMessage.uuid).addClass("active");
// Scroll to selected message if needed.
scollToActiveMessageIfNeeded();
}
// We are no longer loading.
loading = false;
// If search query was updated during loading, we need to re-load the message list.
if (shouldSearch) {
loadMessageList();
}
})
.fail(function(jqXHR, textStatus) {
// On failure, we need to display a message.
displayError("Unable to pull messages: "+textStatus);
// We are no longer loading.
loading = false;
// If search query was updated during loading, we need to re-load the message list.
if (shouldSearch) {
loadMessageList();
}
});
}
// When the search box has received input, we need to re-load the messages.
function handleSearchInput() {
// If we are already loading the message list, we need to load again after it completes.
if (loading) {
shouldSearch = true;
} else {
// Load messages now.
loadMessageList();
}
}
// Every 5 seconds, we check to see if we need to refresh the message list.
function checkIfRefreshNeeded() {
// If we need to refresh, then we load the message list again.
if (shouldRefresh) {
loadMessageList();
}
}
// This function handles the connection to the websocket and reconnects if needed.
function connectToWS() {
// Connect to the websockets address.
displayMessage("Connecting to websockets daemon");
var ws = new WebSocket("ws://"+document.location.host+"/ws");
// On error, we can display the error and close the connection.
ws.onerror = function(err) {
displayError(err, "red", false);
ws.close();
}
// On connection complete, we just display a message.
ws.onopen = function() {
displaySuccess("Connected to websockets daemon");
}
// When the connection is closed, we need to try reconnecting in 5 seconds.
ws.onclose = function() {
displayMessage("Websockets connection closed", "cadetblue", false);
setTimeout(connectToWS, 5000);
}
// When we receive a message, we need to parse it.
ws.onmessage = function(event) {
// Parse the json data.
var message = JSON.parse(event.data);
// If parse error, we display a message.
if (message==undefined) {
displayError("Received weird response: "+event.data);
} else {
// On good message, we process it.
switch (message.type) {
case "messageStatusesUpdated": // A message's delievery status was updated.
// Set that we need to refresh the message list.
shouldRefresh = true;
break;
case "receivedNewMessage": // A new message has been received.
// Set that we need to refresh the message list.
shouldRefresh = true;
break;
case "updateMessageCount": // The message count has changed.
// Update the emssage count header.
$("#message_count").text(message.msg.toLocaleString());
default:
// If we do not have a condition for the message, we just log it to the javascript console.
console.log(message);
}
}
}
}
// Load a message by its UUID.
function loadMessage(UUID) {
// If the message being loaded is already selected, we can stop here.
if (selectedMessage!=null && selectedMessage.uuid==UUID) {
return;
}
// Call the API for the message.
$.ajax({
dataType: "json",
type: "GET",
url: "/api/message/"+UUID
})
.done(function(data) {
// If an error ocurred. Display it.
if (data.status=="error") {
displayError("Unable to pull message: "+data.error);
return;
}
// Update the selected message to this one.
selectedMessage = data.message
if (selectedMessage!=null) {
// Add a formated date for this message.
selectedMessage.formatted_date = moment(selectedMessage.received).format('YYYY-MM-DD HH:mm:ss');
// Update the selected message data.
updateSelectedMessage();
// Update the selected message in the message list.
$("#message_list tr.active").removeClass("active");
$("#"+selectedMessage.uuid).addClass("active");
scollToActiveMessageIfNeeded();
}
})
.fail(function(jqXHR, textStatus) {
// On error, display a message.
displayError("Unable to pull message: "+textStatus);
});
}
// When a source tab is selected, we need to grab the source from the API.
function handleSourceSelection() {
// If no message is selected, we should stop here.
if (selectedMessage==null) {
return;
}
// Get the selected source type.
var selection = $(this);
// Update the active source type tab.
$("#message_header .nav-tabs .active").removeClass("active");
selection.addClass("active");
// Determine the extension for selected soruce type.
var extension = ".txt";
if (selection.hasClass("html")) {
extension = ".html";
} else if (selection.hasClass("source")) {
extension = ".eml";
} else if (selection.hasClass("log")) {
extension = ".log";
}
// If source type is HTML, we must do something special.
if (extension==".html") {
// Create an ifram with the html from the API.
var iframe = $("<iframe>");
iframe.css("height", "100%");
iframe.css("width", "100%");
iframe.attr("src", "/api/message/"+selectedMessage.uuid+extension);
// Append iframe to the message contents view.
$("#message_contents").html("");
$("#message_contents").append(iframe);
} else {
// All other source types are handled here.
// Get the message source from the API.
$.ajax({
url: "/api/message/"+selectedMessage.uuid+extension
})
.done(function(data) {
// If an error was returned, we display it.
if (data.status!=undefined) {
displayError("Unable to pull message: "+data.error);
return;
}
// We display plain text message contents in a pre-formatted element.
var preFormated = $("<pre>").text(data);
// Append the pre-formatted element to the message contents.
$("#message_contents").html("");
$("#message_contents").append(preFormated);
})
.fail(function(jqXHR, textStatus) {
// Om error, display a message.
displayError("Unable to pull message: "+textStatus);
});
}
}
// This function is used to update the currently selected message view.
function updateSelectedMessage() {
// Update the header information.
$("#message_header .received").text(selectedMessage.formatted_date);
$("#message_header .size").text(bytesToHuman(selectedMessage.size));
$("#message_header .from").text(selectedMessage.from);
$("#message_header .to").text(selectedMessage.to);
$("#message_header .subject").text(selectedMessage.subject);
$("#message_header .spam_score").text(selectedMessage.spam_score);
$("#message_header .status").text(selectedMessage.status);
$("#message_header .source_ip").text(selectedMessage.source_ip);
// If no plain text, this must be a html email.
if (!selectedMessage.plain_text) {
// Disable plain text source selection tab.
$("#message_header .nav-tabs .plaintext").prop("disabled", true);
// Enable the html source selection tab.
$("#message_header .nav-tabs .html").prop("disabled", false);
// If the currently selected source tab is disabled, we need to select html.
if ($("#message_header .nav-tabs .active").prop("disabled")) {
$("#message_header .nav-tabs .html").click();
} else {
// Otherwise, select the active tab.
$("#message_header .nav-tabs .active").click();
}
} else {
// When plaintext is avaiable, we need to disable HTML source selection only if there is no HTML.
$("#message_header .nav-tabs .html").prop("disabled", !selectedMessage.html);
// We can enable the plain text source selection.
$("#message_header .nav-tabs .plaintext").prop("disabled", false);
// If the currently selected source tab is disabled, we need to select plain text.
if ($("#message_header .nav-tabs .active").prop("disabled")) {
$("#message_header .nav-tabs .plaintext").click();
} else {
// Otherwise, select the active tab.
$("#message_header .nav-tabs .active").click();
}
}
}
// When a message is selected in the message list, this function is called.
function handleMessageListSelection() {
// Get the selected message data.
var selection = $(this);
var message = JSON.parse(selection.attr("data"));
selectedMessage = message;
// Change the selelected message in the message list to this selection.
$("#message_list tr.active").removeClass("active");
selection.addClass("active");
// Update the location hash URI to this message.
window.location.hash = "uuid="+message.uuid;
// Update the selected message.
updateSelectedMessage();
}
// When the learn ham spam reporting button is clicked, this function is called.
function learnHam() {
// If no message is selected, we display a message and stop here.
if (selectedMessage==null) {
displayMessage("Select a message first.");
return;
}
// Confirm that this action is actually wanted to occur.
var r = confirm("Are you sure you want to report as ham?");
if (r != true) {
return
}
// Send request to the API.
$.ajax({
dataType: "json",
type: 'PUT',
url: "/api/message/"+selectedMessage.uuid+"/learn_ham"
})
.done(function(data) {
// If error, display message.
if (data.status=="error") {
displayError("Unable to report ham: "+data.error);
return;
}
// We Successfully submitted a report.
displaySuccess("Successfully reported as ham.");
})
.fail(function(jqXHR, textStatus) {
// On error, display message.
displayError("Unable to report ham: "+textStatus);
});
}
// When the learn spam spam reporting button is clicked, this function is called.
function learnSpam() {
// If no message is selected, we display a message and stop here.
if (selectedMessage==null) {
displayMessage("Select a message first.");
return;
}
// Confirm that this action is actually wanted to occur.
var r = confirm("Are you sure you want to report as spam?");
if (r != true) {
return
}
// Send request to the API.
$.ajax({
dataType: "json",
type: "PUT",
url: "/api/message/"+selectedMessage.uuid+"/learn_spam"
})
.done(function(data) {
// If error, display message.
if (data.status=="error") {
displayError("Unable to report spam: "+data.error);
return;
}
// We Successfully submitted a report.
displaySuccess("Successfully reported as spam.");
})
.fail(function(jqXHR, textStatus) {
// On error, display message.
displayError("Unable to report spam: "+textStatus);
});
}
// When the document has fully loaded, we get everything started.
$(document).ready(function() {
// Connect to websockets if available.
if (!window["WebSocket"]) {
displayError("Your browser does not support websockets, auto refresh will only occur once every minute.");
return;
} else {
connectToWS();
}
// Laod the configuration from API.
loadConfig();
// Make the message list resizer element work.
makeMessageListResizable();
// On window resize events, adjust view sizes.
$(window).resize(handleResize);
// Update the view sizes.
setTimeout(handleResize, 200);
// Load the message list.
loadMessageList();
// Every 5 seconds, we need to check if we need to refresh the message list.
setInterval(checkIfRefreshNeeded, 5000);
// Every minute, we force a refresh.
setInterval(function() {
shouldRefresh = true;
}, 60000);
// On input in the search field, handle it.
$("#searchInput").on("input", handleSearchInput);
// Handle clicking on items in the message list.
$("#message_list").on("click", "tr", handleMessageListSelection);
// Handle global document key down events.
$(document).keydown(handleKeydownEvent);
// Handle clicks on source selection tabs.
$("#message_header .nav-tabs .nav-link").click(handleSourceSelection);
// Handle a click on the email download button.
$("#mailDownloadButton").click(function() {
// If no message selected, stop here.
if (selectedMessage==null) {
return;
}
// Setup the download path.
var downloadPath = "/api/message/"+selectedMessage.uuid+".eml";
// Create an link with a download file name to allow downloading without navigating away.
// This is done to avoid disconnection from the websocket.
var a = document.createElement("a");
a.href = downloadPath;
a.download = downloadPath.substr(downloadPath.lastIndexOf('/') + 1);
// Append the link, click it and remove it.
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
// Setup handlers for spam reporting.
$("#mailLearnHamButton").click(learnHam);
$("#mailLearnSpamButton").click(learnSpam);
// Check if a message uuid was provided in the location hash.
var hashParams = new URLSearchParams(window.location.hash.slice(1));
if (hashParams.has("uuid")) {
// If it was, we should load that message.
var uuid = hashParams.get("uuid");
loadMessage(uuid);
}
// Register for when the hash location has changed.
$(window).bind('hashchange', function(e) {
// Check if the UUID is provided in the updated hash location.
var hashParams = new URLSearchParams(window.location.hash.slice(1));
if (hashParams.has("uuid")) {
// If it was, we can load the message.
var uuid = hashParams.get("uuid");
loadMessage(uuid);
}
});
});