/*
Mail — message rendering
HTML mail is shown in an iframe that is sandboxed without scripts, forms
or top navigation, and carries a Content-Security-Policy that blocks every
network load unless the user allowed remote content. The server already
sanitised the HTML; the sandbox is the security boundary. The frame is
same-origin only so its height can follow the content and links can be
previewed and intercepted (mailto: opens the composer).
*/
var Mail = window.Mail || {};
window.Mail = Mail;
Mail.render = (function () {
"use strict";
var util = Mail.util;
var el = util.el;
var icon = util.icon;
var FRAME_STYLE =
"html{overflow-y:hidden;overflow-x:auto;}" +
"body{margin:0;padding:18px 20px;background:#fff;color:#1d2433;" +
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue','Noto Sans','PingFang TC','Microsoft JhengHei',sans-serif;" +
"font-size:14px;line-height:1.55;overflow-wrap:anywhere;}" +
"img{max-width:100%;height:auto;}" +
"img[data-remote-src]{display:inline-block;min-width:12px;min-height:12px;background:#eef1f6;}" +
//Blocked tracking pixels would otherwise show up as small grey squares
"img[data-remote-src][width='0'],img[data-remote-src][width='1'],img[data-remote-src][height='0'],img[data-remote-src][height='1']{display:none;}" +
"pre{white-space:pre-wrap;}" +
"blockquote{margin:0 0 0 6px;padding-left:12px;border-left:3px solid #d5dae3;color:#4a5466;}" +
"a{color:#1f63d8;}";
//frameDocument prepares the srcdoc of a sanitised HTML message
function frameDocument(html, allowRemote) {
var remote = allowRemote ? " https: http:" : "";
var csp = "default-src 'none'; img-src data: blob:" + remote + "; style-src 'unsafe-inline'" + remote +
"; font-src data:" + remote + "; media-src data:" + remote + "; script-src 'none'; form-action 'none'; frame-src 'none'";
var injected = '' +
'";
html = String(html || "");
var headMatch = html.match(/
]*>/i);
if (headMatch) {
return html.replace(headMatch[0], headMatch[0] + injected);
}
return "" + injected + "" + html + "";
}
//htmlFrame renders sanitised HTML into a self-sizing sandboxed iframe
function htmlFrame(container, html, options) {
options = options || {};
var frame = el("iframe", {
sandbox: "allow-same-origin allow-popups allow-popups-to-escape-sandbox",
referrerpolicy: "no-referrer",
title: "Message content"
});
var preview = el("div", { class: "link-preview hidden" });
container.appendChild(frame);
container.appendChild(preview);
var observer = null;
var lastHeight = -1;
var ready = false;
//contentHeight measures the content itself. The document and body
//cannot be used: they are at least as tall as the frame (mail without
//a doctype renders in quirks mode, where the body fills the viewport),
//so measuring them feeds the frame's own height back and it creeps.
var contentHeight = function (doc) {
var body = doc.body;
var view = doc.defaultView;
var range = doc.createRange();
range.selectNodeContents(body);
var rect = range.getBoundingClientRect();
if (!rect || (rect.height === 0 && rect.bottom === 0)) {
return 60;
}
var style = view.getComputedStyle(body);
var bottom = rect.bottom + view.scrollY + (parseFloat(style.paddingBottom) || 0) +
(parseFloat(style.marginBottom) || 0) + (parseFloat(style.borderBottomWidth) || 0);
//Room for the horizontal scroll bar of wide newsletters
if (doc.documentElement.scrollWidth > doc.documentElement.clientWidth + 1) {
bottom += 18;
}
return Math.max(60, Math.ceil(bottom));
};
var resize = function () {
try {
var doc = frame.contentDocument;
if (!doc || !doc.body) {
return;
}
var height = contentHeight(doc);
if (Math.abs(height - lastHeight) > 1) {
lastHeight = height;
frame.style.height = height + "px";
}
if (!ready) {
ready = true;
frame.style.visibility = "visible";
if (options.onReady) {
options.onReady();
}
}
} catch (e) { /* frame navigated away */ }
};
//Hidden and flat until measured, so nothing below it jumps around
frame.style.height = "0px";
frame.style.visibility = "hidden";
frame.addEventListener("load", function () {
var doc;
try {
doc = frame.contentDocument;
} catch (e) {
return;
}
if (!doc) {
return;
}
resize();
doc.querySelectorAll("img").forEach(function (image) {
image.addEventListener("load", resize);
image.addEventListener("error", resize);
});
if (window.ResizeObserver && doc.body) {
observer = new ResizeObserver(resize);
observer.observe(doc.body);
}
//Web fonts may still change the layout once
if (doc.fonts && doc.fonts.ready) {
doc.fonts.ready.then(resize).catch(function () { });
}
doc.addEventListener("click", function (event) {
var link = event.target.closest ? event.target.closest("a[href]") : null;
if (!link) {
return;
}
var href = link.getAttribute("href") || "";
if (/^mailto:/i.test(href)) {
event.preventDefault();
if (options.onMailto) {
options.onMailto(href);
}
} else if (href.charAt(0) === "#") {
event.preventDefault();
var target = doc.getElementById(href.slice(1)) || doc.querySelector('[name="' + CSS.escape(href.slice(1)) + '"]');
if (target) {
target.scrollIntoView();
}
}
});
//Show where a link really goes, a basic phishing aid
doc.addEventListener("mouseover", function (event) {
var link = event.target.closest ? event.target.closest("a[href]") : null;
if (link) {
preview.textContent = link.getAttribute("href");
preview.classList.remove("hidden");
} else {
preview.classList.add("hidden");
}
});
doc.addEventListener("mouseleave", function () { preview.classList.add("hidden"); });
});
frame.srcdoc = frameDocument(html, options.allowRemote);
return {
frame: frame,
destroy: function () {
if (observer) {
observer.disconnect();
}
}
};
}
function plainBody(container, text, options) {
var node = el("div", { class: "plain-body" });
node.appendChild(util.linkifyText(text || "", options && options.onMailto ? function (address) {
options.onMailto("mailto:" + address);
} : null));
container.appendChild(node);
}
//body renders a message's content (HTML or plain text)
function body(container, message, options) {
options = options || {};
if (message.html) {
return htmlFrame(container, message.html, {
allowRemote: message.remoteAllowed,
onMailto: options.onMailto,
onReady: options.onReady
});
}
if (message.text) {
plainBody(container, message.text, options);
} else {
container.appendChild(el("div", { class: "muted", text: message.encrypted ? "This message is encrypted." : "This message has no text." }));
}
if (options.onReady) {
options.onReady();
}
return null;
}
function addressSpan(address, onClick) {
var span = el("span", { text: util.addressName(address), title: address.email });
if (onClick) {
span.style.cursor = "pointer";
span.addEventListener("click", function () { onClick(address); });
}
return span;
}
function addressList(list, onClick) {
var fragment = document.createDocumentFragment();
(list || []).forEach(function (address, index) {
if (index > 0) {
fragment.appendChild(document.createTextNode(", "));
}
fragment.appendChild(addressSpan(address, onClick));
});
return fragment;
}
//header renders sender, recipients, date and quick actions
function header(message, options) {
options = options || {};
var from = (message.from && message.from[0]) || { name: "", email: "" };
var wrap = el("div", { class: "reader-head" });
wrap.appendChild(util.avatar(from, "large"));
var who = el("div", { class: "who" });
var sender = el("div", { class: "sender" }, [
el("span", { class: "name", text: from.name || from.email || "(unknown sender)" }),
from.name ? el("span", { class: "addr", text: "<" + from.email + ">" }) : null
]);
who.appendChild(sender);
var recipients = el("div", { class: "recipients" });
if (message.to && message.to.length > 0) {
recipients.appendChild(document.createTextNode("To: "));
recipients.appendChild(addressList(message.to.slice(0, 4), options.onAddress));
if (message.to.length > 4) {
recipients.appendChild(document.createTextNode(" and " + (message.to.length - 4) + " more"));
}
} else {
recipients.appendChild(document.createTextNode("To: (undisclosed recipients)"));
}
var details = el("div", { class: "details" });
var detailRow = function (label, content) {
details.appendChild(el("div", {}, [el("b", { text: label }), content]));
};
detailRow("From:", document.createTextNode(util.addressFull(from)));
if (message.replyTo && message.replyTo.length > 0) {
detailRow("Reply-To:", document.createTextNode(message.replyTo.map(util.addressFull).join(", ")));
}
detailRow("To:", document.createTextNode((message.to || []).map(util.addressFull).join(", ") || "-"));
if (message.cc && message.cc.length > 0) {
detailRow("Cc:", document.createTextNode(message.cc.map(util.addressFull).join(", ")));
}
if (message.bcc && message.bcc.length > 0) {
detailRow("Bcc:", document.createTextNode(message.bcc.map(util.addressFull).join(", ")));
}
detailRow("Date:", document.createTextNode(util.formatFullDate(message.date)));
if (message.auth) {
var verdicts = [];
["spf", "dkim", "dmarc"].forEach(function (key) {
if (message.auth[key]) {
verdicts.push(key.toUpperCase() + " " + message.auth[key]);
}
});
detailRow("Security:", document.createTextNode(verdicts.join(" · ")));
}
var toggle = el("span", { class: "toggle", title: "Show details" }, icon("caret down"));
toggle.addEventListener("click", function () {
details.classList.toggle("show");
toggle.firstChild.className = details.classList.contains("show") ? "caret up icon" : "caret down icon";
});
if (message.cc && message.cc.length > 0) {
recipients.appendChild(document.createTextNode(" · Cc: "));
recipients.appendChild(addressList(message.cc.slice(0, 3), options.onAddress));
}
recipients.appendChild(toggle);
who.appendChild(recipients);
who.appendChild(details);
wrap.appendChild(who);
var meta = el("div", { class: "meta" }, [el("div", { class: "date", text: util.formatReaderDate(message.date), title: util.formatFullDate(message.date) })]);
if (options.actions) {
meta.appendChild(options.actions);
}
wrap.appendChild(meta);
return wrap;
}
//notices explains blocked images, signatures and failed authentication
function notices(message, handlers) {
handlers = handlers || {};
var wrap = el("div", { class: "reader-notices" });
var add = function (kind, iconName, text, actions) {
var content = el("div", { class: "grow" }, [el("div", { text: text })]);
if (actions && actions.length > 0) {
var row = el("div", { class: "actions" });
actions.forEach(function (action) {
row.appendChild(el("button", { class: "btn small", text: action.label, on: { click: action.onClick } }));
});
content.appendChild(row);
}
wrap.appendChild(el("div", { class: "notice " + kind }, [icon(iconName), content]));
};
var auth = message.auth;
if (auth && (auth.dmarc === "fail" || (auth.spf === "fail" && auth.dkim !== "pass"))) {
add("danger", "exclamation triangle", "This message failed sender verification. It may not really be from " +
(((message.from || [])[0] || {}).email || "the address shown") + ". Be careful with links and attachments.");
}
if (message.hasRemoteContent && !message.remoteAllowed) {
var sender = ((message.from || [])[0] || {}).email || "";
var actions = [{ label: "Load images", onClick: function () { if (handlers.loadRemote) { handlers.loadRemote(); } } }];
if (sender && handlers.trustSender) {
actions.push({ label: "Always load from " + sender, onClick: function () { handlers.trustSender(sender); } });
}
add("", "eye slash outline", "Images and other remote content are blocked to protect your privacy.", actions);
}
if (message.encrypted) {
add("warning", "lock", "This message is encrypted (S/MIME or PGP) and cannot be read in ArozOS Mail.");
} else if (message.signed) {
add("success", "certificate", "This message is digitally signed.");
}
if (message.calendar) {
add("", "calendar alternate outline", "This message contains a calendar invitation (.ics attachment).");
}
if (message.readReceiptTo) {
add("", "info circle", "The sender asked for a read receipt. ArozOS Mail does not send receipts automatically.");
}
return wrap.childNodes.length > 0 ? wrap : null;
}
//attachments renders attachment cards. handlers: {open, save, download, saveAll}
function attachments(list, handlers) {
//The server already leaves out images embedded in the HTML body
list = list || [];
if (list.length === 0) {
return null;
}
handlers = handlers || {};
var total = list.reduce(function (sum, item) { return sum + (item.size || 0); }, 0);
var wrap = el("div", { class: "attachments" });
var head = el("div", { class: "head" }, [
icon("paperclip"),
el("span", { class: "grow", text: util.plural(list.length, "attachment") + " · " + util.formatSize(total) })
]);
if (handlers.saveAll && list.length > 1) {
head.appendChild(el("button", { class: "btn small", on: { click: function () { handlers.saveAll(list); } } }, [icon("folder open outline"), "Save all to ArozOS"]));
}
wrap.appendChild(head);
var grid = el("div", { class: "attachment-grid" });
list.forEach(function (attachment) {
var kind = util.fileIcon(attachment.contentType, attachment.filename);
var acts = el("div", { class: "acts" });
if (handlers.open) {
acts.appendChild(el("button", { class: "iconbtn small", title: "Open in ArozOS", on: { click: function () { handlers.open(attachment); } } }, icon("external alternate")));
}
if (handlers.save) {
acts.appendChild(el("button", { class: "iconbtn small", title: "Save to ArozOS", on: { click: function () { handlers.save(attachment); } } }, icon("folder open outline")));
}
if (handlers.download) {
acts.appendChild(el("button", { class: "iconbtn small", title: "Download", on: { click: function () { handlers.download(attachment); } } }, icon("download")));
}
var card = el("div", { class: "attachment", title: attachment.filename }, [
el("i", { class: kind.icon + " icon ficon " + kind.kind }),
el("div", { class: "info" }, [
el("div", { class: "name", text: attachment.filename }),
el("div", { class: "size", text: util.formatSize(attachment.size) })
]),
acts
]);
card.addEventListener("dblclick", function () {
if (handlers.open) {
handlers.open(attachment);
}
});
grid.appendChild(card);
});
wrap.appendChild(grid);
return wrap;
}
return {
frameDocument: frameDocument, htmlFrame: htmlFrame, body: body, header: header,
notices: notices, attachments: attachments, addressList: addressList
};
})();