2025-06-20 23:26:06 +12:00
|
|
|
<script>
|
|
|
|
import Attachments from "./MessageAttachments.vue";
|
|
|
|
import Headers from "./MessageHeaders.vue";
|
|
|
|
import HTMLCheck from "./HTMLCheck.vue";
|
|
|
|
import LinkCheck from "./LinkCheck.vue";
|
|
|
|
import SpamAssassin from "./SpamAssassin.vue";
|
|
|
|
import Tags from "bootstrap5-tags";
|
|
|
|
import { Tooltip } from "bootstrap";
|
|
|
|
import commonMixins from "../../mixins/CommonMixins";
|
|
|
|
import { mailbox } from "../../stores/mailbox";
|
|
|
|
import DOMPurify from "dompurify";
|
|
|
|
import hljs from "highlight.js/lib/core";
|
|
|
|
import xml from "highlight.js/lib/languages/xml";
|
|
|
|
|
|
|
|
hljs.registerLanguage("html", xml);
|
|
|
|
|
|
|
|
export default {
|
|
|
|
components: {
|
|
|
|
Attachments,
|
|
|
|
Headers,
|
|
|
|
HTMLCheck,
|
|
|
|
LinkCheck,
|
|
|
|
SpamAssassin,
|
|
|
|
},
|
|
|
|
|
|
|
|
mixins: [commonMixins],
|
|
|
|
|
|
|
|
props: {
|
|
|
|
message: {
|
|
|
|
type: Object,
|
|
|
|
required: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
|
|
|
|
emits: ["loadMessages"],
|
|
|
|
|
|
|
|
data() {
|
|
|
|
return {
|
|
|
|
mailbox,
|
|
|
|
srcURI: false,
|
|
|
|
iframes: [], // for resizing
|
|
|
|
canSaveTags: false, // prevent auto-saving tags on render
|
|
|
|
availableTags: [],
|
|
|
|
messageTags: [],
|
|
|
|
loadHeaders: false,
|
|
|
|
htmlScore: false,
|
|
|
|
htmlScoreColor: false,
|
|
|
|
linkCheckErrors: false,
|
|
|
|
spamScore: false,
|
|
|
|
spamScoreColor: false,
|
|
|
|
showMobileButtons: false,
|
|
|
|
showUnsubscribe: false,
|
|
|
|
scaleHTMLPreview: "display",
|
|
|
|
// keys names match bootstrap icon names
|
|
|
|
responsiveSizes: {
|
|
|
|
phone: "width: 322px; height: 570px",
|
|
|
|
tablet: "width: 768px; height: 1024px",
|
|
|
|
display: "width: 100%; height: 100%",
|
|
|
|
},
|
|
|
|
};
|
|
|
|
},
|
|
|
|
|
|
|
|
computed: {
|
|
|
|
hasAnyChecksEnabled() {
|
|
|
|
return (
|
|
|
|
(mailbox.showHTMLCheck && this.message.HTML) ||
|
|
|
|
mailbox.showLinkCheck ||
|
|
|
|
(mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
|
|
|
);
|
|
|
|
},
|
|
|
|
|
|
|
|
// remove bad HTML, JavaScript, iframes etc
|
|
|
|
sanitizedHTML() {
|
|
|
|
// set target & rel on all links
|
|
|
|
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
|
|
|
if (
|
|
|
|
node.tagName !== "A" ||
|
|
|
|
(node.hasAttribute("href") && node.getAttribute("href").substring(0, 1) === "#")
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if ("target" in node) {
|
|
|
|
node.setAttribute("target", "_blank");
|
|
|
|
node.setAttribute("rel", "noopener noreferrer");
|
|
|
|
}
|
|
|
|
if (!node.hasAttribute("target") && (node.hasAttribute("xlink:href") || node.hasAttribute("href"))) {
|
|
|
|
node.setAttribute("xlink:show", "_blank");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
const clean = DOMPurify.sanitize(this.message.HTML, {
|
|
|
|
WHOLE_DOCUMENT: true,
|
|
|
|
SANITIZE_DOM: false,
|
|
|
|
ADD_TAGS: ["link", "meta", "o:p", "style"],
|
|
|
|
ADD_ATTR: [
|
|
|
|
"bordercolor",
|
|
|
|
"charset",
|
|
|
|
"content",
|
|
|
|
"hspace",
|
|
|
|
"http-equiv",
|
|
|
|
"itemprop",
|
|
|
|
"itemscope",
|
|
|
|
"itemtype",
|
|
|
|
"link",
|
|
|
|
"vertical-align",
|
|
|
|
"vlink",
|
|
|
|
"vspace",
|
|
|
|
"xml:lang",
|
|
|
|
],
|
2025-07-01 08:01:09 +12:00
|
|
|
FORBID_ATTR: ["script"], // all JavaScript should be removed
|
|
|
|
ALLOW_UNKNOWN_PROTOCOLS: true, // allow link href protocols like myapp:// etc
|
2025-06-20 23:26:06 +12:00
|
|
|
});
|
|
|
|
|
|
|
|
// for debugging
|
2025-07-01 08:01:09 +12:00
|
|
|
// this.debugDOMPurify(DOMPurify.removed);
|
2025-06-20 23:26:06 +12:00
|
|
|
|
|
|
|
return clean;
|
|
|
|
},
|
|
|
|
},
|
|
|
|
|
|
|
|
watch: {
|
|
|
|
messageTags() {
|
|
|
|
if (this.canSaveTags) {
|
|
|
|
// save changes to tags
|
|
|
|
this.saveTags();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
scaleHTMLPreview(v) {
|
|
|
|
if (v === "display") {
|
|
|
|
window.setTimeout(() => {
|
|
|
|
this.resizeIFrames();
|
|
|
|
}, 500);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
},
|
|
|
|
|
|
|
|
mounted() {
|
|
|
|
this.canSaveTags = false;
|
|
|
|
this.messageTags = this.message.Tags;
|
|
|
|
this.renderUI();
|
|
|
|
|
|
|
|
window.addEventListener("resize", this.resizeIFrames);
|
|
|
|
|
|
|
|
const headersTab = document.getElementById("nav-headers-tab");
|
|
|
|
headersTab.addEventListener("shown.bs.tab", (event) => {
|
|
|
|
this.loadHeaders = true;
|
|
|
|
});
|
|
|
|
|
|
|
|
const rawTab = document.getElementById("nav-raw-tab");
|
|
|
|
rawTab.addEventListener("shown.bs.tab", (event) => {
|
|
|
|
this.srcURI = this.resolve("/api/v1/message/" + this.message.ID + "/raw");
|
|
|
|
this.resizeIFrames();
|
|
|
|
});
|
|
|
|
|
|
|
|
// manually refresh tags
|
|
|
|
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
|
|
|
|
this.availableTags = response.data;
|
|
|
|
this.$nextTick(() => {
|
|
|
|
Tags.init("select[multiple]");
|
|
|
|
// delay tag change detection to allow Tags to load
|
|
|
|
window.setTimeout(() => {
|
|
|
|
this.canSaveTags = true;
|
|
|
|
}, 200);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
methods: {
|
|
|
|
isHTMLTabSelected() {
|
|
|
|
this.showMobileButtons = this.$refs.navhtml && this.$refs.navhtml.classList.contains("active");
|
|
|
|
},
|
|
|
|
|
|
|
|
renderUI() {
|
|
|
|
// activate the first non-disabled tab
|
|
|
|
document.querySelector("#nav-tab button:not([disabled])").click();
|
|
|
|
document.activeElement.blur(); // blur focus
|
|
|
|
document.getElementById("message-view").scrollTop = 0;
|
|
|
|
|
|
|
|
this.isHTMLTabSelected();
|
|
|
|
|
|
|
|
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
|
|
|
|
listObj.addEventListener("shown.bs.tab", (event) => {
|
|
|
|
this.isHTMLTabSelected();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
|
|
|
[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
|
|
|
|
|
|
|
|
// delay 0.5s until vue has rendered the iframe content
|
|
|
|
window.setTimeout(() => {
|
|
|
|
const p = document.getElementById("preview-html");
|
|
|
|
if (p && typeof p.contentWindow.document.body === "object") {
|
|
|
|
try {
|
|
|
|
// make links open in new window
|
|
|
|
const anchorEls = p.contentWindow.document.body.querySelectorAll("a");
|
|
|
|
for (let i = 0; i < anchorEls.length; i++) {
|
|
|
|
const anchorEl = anchorEls[i];
|
|
|
|
const href = anchorEl.getAttribute("href");
|
|
|
|
|
|
|
|
if (href && href.match(/^https?:\/\//i)) {
|
|
|
|
anchorEl.setAttribute("target", "_blank");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (error) {}
|
|
|
|
this.resizeIFrames();
|
|
|
|
}
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
// HTML highlighting
|
|
|
|
hljs.highlightAll();
|
|
|
|
},
|
|
|
|
|
|
|
|
resizeIframe(el) {
|
|
|
|
const i = el.target;
|
|
|
|
if (typeof i.contentWindow.document.body.scrollHeight === "number") {
|
|
|
|
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + "px";
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
resizeIFrames() {
|
|
|
|
if (this.scaleHTMLPreview !== "display") {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const h = document.getElementById("preview-html");
|
|
|
|
if (h) {
|
|
|
|
if (typeof h.contentWindow.document.body.scrollHeight === "number") {
|
|
|
|
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
// set the iframe body & text colors based on current theme
|
|
|
|
initRawIframe(el) {
|
|
|
|
const bodyStyles = window.getComputedStyle(document.body, null);
|
|
|
|
const bg = bodyStyles.getPropertyValue("background-color");
|
|
|
|
const txt = bodyStyles.getPropertyValue("color");
|
|
|
|
|
|
|
|
const body = el.target.contentWindow.document.querySelector("body");
|
|
|
|
if (body) {
|
|
|
|
body.style.color = txt;
|
|
|
|
body.style.backgroundColor = bg;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.resizeIframe(el);
|
|
|
|
},
|
|
|
|
|
|
|
|
// this function is unused but kept here to use for debugging
|
|
|
|
debugDOMPurify(removed) {
|
|
|
|
if (!removed.length) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const ignoreNodes = ["target", "base", "script", "v:shapes"];
|
|
|
|
|
|
|
|
const d = removed.filter((r) => {
|
|
|
|
if (
|
|
|
|
typeof r.attribute !== "undefined" &&
|
|
|
|
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith("xmlns:"))
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
// inline comments
|
|
|
|
if (typeof r.element !== "undefined" && (r.element.nodeType === 8 || r.element.tagName === "SCRIPT")) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (d.length) {
|
|
|
|
console.log(d);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
saveTags() {
|
|
|
|
const data = {
|
|
|
|
IDs: [this.message.ID],
|
|
|
|
Tags: this.messageTags,
|
|
|
|
};
|
|
|
|
|
|
|
|
this.put(this.resolve("/api/v1/tags"), data, (response) => {
|
|
|
|
window.scrollInPlace = true;
|
|
|
|
this.$emit("loadMessages");
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
// Convert plain text to HTML including anchor links
|
|
|
|
textToHTML(s) {
|
|
|
|
let html = s;
|
|
|
|
|
|
|
|
// full links with http(s)
|
|
|
|
const re = /(\b(https?|ftp):\/\/[-\w@:%_+'!.~#?,&//=;]+)/gim;
|
|
|
|
html = html.replace(re, "˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲");
|
|
|
|
|
|
|
|
// plain www links without https?:// prefix
|
|
|
|
const re2 = /(^|[^/])(www\.[\S]+(\b|$))/gim;
|
|
|
|
html = html.replace(re2, "$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲");
|
|
|
|
|
|
|
|
// escape to HTML & convert <>" back
|
|
|
|
html = html
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
.replace(/</g, "<")
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
.replace(/"/g, """)
|
|
|
|
.replace(/'/g, "'")
|
|
|
|
.replace(/˱˱˱/g, "<")
|
|
|
|
.replace(/˲˲˲/g, ">")
|
|
|
|
.replace(/ˠˠˠ/g, '"');
|
|
|
|
|
|
|
|
return html;
|
|
|
|
},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
|
|
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
|
|
|
<div class="row w-100">
|
|
|
|
<div class="col-md">
|
|
|
|
<table class="messageHeaders">
|
|
|
|
<tbody>
|
|
|
|
<tr>
|
|
|
|
<th class="small">From</th>
|
|
|
|
<td class="privacy">
|
|
|
|
<span v-if="message.From">
|
|
|
|
<span v-if="message.From.Name" class="text-spaces">
|
|
|
|
{{ message.From.Name + " " }}
|
|
|
|
</span>
|
|
|
|
<span v-if="message.From.Address" class="small">
|
|
|
|
<<a :href="searchURI(message.From.Address)" class="text-body">
|
|
|
|
{{ message.From.Address }} </a
|
|
|
|
>>
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
<span v-else> [ Unknown ] </span>
|
|
|
|
|
|
|
|
<span
|
|
|
|
v-if="message.ListUnsubscribe.Header != ''"
|
|
|
|
class="small ms-3 link"
|
|
|
|
:title="
|
|
|
|
showUnsubscribe
|
|
|
|
? 'Hide unsubscribe information'
|
|
|
|
: 'Show unsubscribe information'
|
|
|
|
"
|
|
|
|
@click="showUnsubscribe = !showUnsubscribe"
|
|
|
|
>
|
|
|
|
Unsubscribe
|
|
|
|
<i
|
|
|
|
class="bi bi bi-info-circle"
|
|
|
|
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"
|
|
|
|
></i>
|
|
|
|
</span>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr class="small">
|
|
|
|
<th>To</th>
|
|
|
|
<td class="privacy">
|
|
|
|
<template v-if="message.To && message.To.length">
|
|
|
|
<span v-for="(t, i) in message.To" :key="'to_' + i">
|
|
|
|
<template v-if="i > 0">, </template>
|
|
|
|
<span>
|
|
|
|
<span class="text-spaces">{{ t.Name }}</span>
|
|
|
|
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a
|
|
|
|
>>
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
</template>
|
|
|
|
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr v-if="message.Cc && message.Cc.length" class="small">
|
|
|
|
<th>Cc</th>
|
|
|
|
<td class="privacy">
|
|
|
|
<span v-for="(t, i) in message.Cc" :key="'cc_' + i">
|
|
|
|
<template v-if="i > 0">,</template>
|
|
|
|
<span class="text-spaces">{{ t.Name }}</span>
|
|
|
|
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>>
|
|
|
|
</span>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
|
|
|
<th>Bcc</th>
|
|
|
|
<td class="privacy">
|
|
|
|
<span v-for="(t, i) in message.Bcc" :key="'bcc_' + i">
|
|
|
|
<template v-if="i > 0">,</template>
|
|
|
|
<span class="text-spaces">{{ t.Name }}</span>
|
|
|
|
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>>
|
|
|
|
</span>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
|
|
|
<th class="text-nowrap">Reply-To</th>
|
|
|
|
<td class="privacy text-body-secondary text-break">
|
|
|
|
<span v-for="(t, i) in message.ReplyTo" :key="'bcc_' + i">
|
|
|
|
<template v-if="i > 0">,</template>
|
|
|
|
<span class="text-spaces">{{ t.Name }}</span>
|
|
|
|
<<a :href="searchURI(t.Address)" class="text-body-secondary"> {{ t.Address }} </a
|
|
|
|
>>
|
|
|
|
</span>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr
|
|
|
|
v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address"
|
|
|
|
class="small"
|
|
|
|
>
|
|
|
|
<th class="text-nowrap">Return-Path</th>
|
|
|
|
<td class="privacy text-body-secondary text-break">
|
|
|
|
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
|
|
|
{{ message.ReturnPath }} </a
|
|
|
|
>>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr>
|
|
|
|
<th class="small">Subject</th>
|
|
|
|
<td>
|
|
|
|
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
|
|
|
|
<small v-else class="text-body-secondary">[ no subject ]</small>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr class="small">
|
|
|
|
<th class="small">Date</th>
|
|
|
|
<td>
|
|
|
|
{{ messageDate(message.Date) }}
|
|
|
|
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr v-if="message.Username" class="small">
|
|
|
|
<th class="small">
|
|
|
|
Username
|
|
|
|
<i
|
|
|
|
class="bi bi-exclamation-circle ms-1"
|
|
|
|
data-bs-toggle="tooltip"
|
|
|
|
data-bs-placement="top"
|
|
|
|
data-bs-custom-class="custom-tooltip"
|
|
|
|
data-bs-title="The SMTP or send API username the client authenticated with"
|
|
|
|
>
|
|
|
|
</i>
|
|
|
|
</th>
|
|
|
|
<td class="small">
|
|
|
|
{{ message.Username }}
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<tr class="small">
|
|
|
|
<th>Tags</th>
|
|
|
|
<td>
|
|
|
|
<select
|
|
|
|
v-model="messageTags"
|
|
|
|
class="form-select small tag-selector"
|
|
|
|
multiple
|
|
|
|
data-full-width="false"
|
|
|
|
data-suggestions-threshold="1"
|
|
|
|
data-allow-new="true"
|
|
|
|
data-clear-end="true"
|
|
|
|
data-allow-clear="true"
|
|
|
|
data-placeholder="Add tags..."
|
|
|
|
data-badge-style="secondary"
|
|
|
|
data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
|
|
|
|
data-separator="|,|"
|
|
|
|
>
|
|
|
|
<option value="">Type a tag...</option>
|
|
|
|
<!-- you need at least one option with the placeholder -->
|
|
|
|
<option v-for="t in availableTags" :key="t" :value="t">{{ t }}</option>
|
|
|
|
</select>
|
|
|
|
<div class="invalid-feedback">Invalid tag name</div>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
<tr
|
|
|
|
v-if="message.ListUnsubscribe.Header != ''"
|
|
|
|
class="small"
|
|
|
|
:class="showUnsubscribe ? '' : 'd-none'"
|
|
|
|
>
|
|
|
|
<th>Unsubscribe</th>
|
|
|
|
<td>
|
|
|
|
<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2">
|
|
|
|
<template v-for="(u, i) in message.ListUnsubscribe.Links">
|
|
|
|
<template v-if="i > 0">, </template>
|
|
|
|
<{{ u }}>
|
|
|
|
</template>
|
|
|
|
</span>
|
|
|
|
<i
|
|
|
|
v-if="message.ListUnsubscribe.HeaderPost != ''"
|
|
|
|
class="bi bi-info-circle text-success me-2 link"
|
|
|
|
data-bs-toggle="tooltip"
|
|
|
|
data-bs-placement="top"
|
|
|
|
data-bs-custom-class="custom-tooltip"
|
|
|
|
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost"
|
|
|
|
>
|
|
|
|
</i>
|
|
|
|
<i
|
|
|
|
v-if="message.ListUnsubscribe.Errors != ''"
|
|
|
|
class="bi bi-exclamation-circle text-danger link"
|
|
|
|
data-bs-toggle="tooltip"
|
|
|
|
data-bs-placement="top"
|
|
|
|
data-bs-custom-class="custom-tooltip"
|
|
|
|
:data-bs-title="message.ListUnsubscribe.Errors"
|
|
|
|
>
|
|
|
|
</i>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
v-if="(message.Attachments && message.Attachments.length) || (message.Inline && message.Inline.length)"
|
|
|
|
class="col-md-auto d-none d-md-block text-end mt-md-3"
|
|
|
|
>
|
|
|
|
<div class="mt-2 mt-md-0">
|
|
|
|
<template v-if="message.Attachments.length">
|
|
|
|
<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message">
|
|
|
|
Attachment<span v-if="message.Attachments.length > 1">s</span> ({{
|
|
|
|
message.Attachments.length
|
|
|
|
}})
|
|
|
|
</span>
|
|
|
|
<br />
|
|
|
|
</template>
|
|
|
|
<span
|
|
|
|
v-if="message.Inline.length"
|
|
|
|
class="badge rounded-pill text-bg-secondary p-2"
|
|
|
|
title="Inline images in this message"
|
|
|
|
>
|
|
|
|
Inline image<span v-if="message.Inline.length > 1">s</span> ({{ message.Inline.length }})
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<nav id="nav-tab" class="nav nav-tabs my-3 d-print-none" role="tablist">
|
|
|
|
<template v-if="message.HTML">
|
|
|
|
<div class="btn-group">
|
|
|
|
<button
|
|
|
|
id="nav-html-tab"
|
|
|
|
ref="navhtml"
|
|
|
|
class="nav-link"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-html"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-html"
|
|
|
|
aria-selected="true"
|
|
|
|
@click="resizeIFrames()"
|
|
|
|
>
|
|
|
|
HTML
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
|
|
|
data-bs-toggle="dropdown"
|
|
|
|
aria-expanded="false"
|
|
|
|
data-bs-reference="parent"
|
|
|
|
>
|
|
|
|
<span class="visually-hidden">Toggle Dropdown</span>
|
|
|
|
</button>
|
|
|
|
<div class="dropdown-menu">
|
|
|
|
<button
|
|
|
|
class="dropdown-item"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-html-source"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-html-source"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
HTML Source
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<button
|
|
|
|
id="nav-html-source-tab"
|
|
|
|
class="nav-link d-none d-sm-inline"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-html-source"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-html-source"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
|
|
|
</button>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<button
|
|
|
|
id="nav-plain-text-tab"
|
|
|
|
class="nav-link"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-plain-text"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-plain-text"
|
|
|
|
aria-selected="false"
|
|
|
|
:class="message.HTML == '' ? 'show' : ''"
|
|
|
|
>
|
|
|
|
Text
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
id="nav-headers-tab"
|
|
|
|
class="nav-link"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-headers"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-headers"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
id="nav-raw-tab"
|
|
|
|
class="nav-link"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-raw"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-raw"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
Raw
|
|
|
|
</button>
|
|
|
|
<div v-show="hasAnyChecksEnabled" class="dropdown d-xl-none">
|
|
|
|
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
|
|
Checks
|
|
|
|
</button>
|
|
|
|
<ul class="dropdown-menu checks">
|
|
|
|
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
|
|
|
<button
|
|
|
|
id="nav-html-check-tab"
|
|
|
|
class="dropdown-item"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-html-check"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-html"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
HTML Check
|
|
|
|
<span
|
|
|
|
v-if="htmlScore !== false"
|
|
|
|
class="badge rounded-pill p-1 float-end"
|
|
|
|
:class="htmlScoreColor"
|
|
|
|
>
|
|
|
|
<small>{{ Math.floor(htmlScore) }}%</small>
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
</li>
|
|
|
|
<li v-if="mailbox.showLinkCheck">
|
|
|
|
<button
|
|
|
|
id="nav-link-check-tab"
|
|
|
|
class="dropdown-item"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-link-check"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-link-check"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
Link Check
|
|
|
|
<span v-if="linkCheckErrors === 0" class="badge rounded-pill bg-success float-end">
|
|
|
|
<small>0</small>
|
|
|
|
</span>
|
|
|
|
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger float-end">
|
|
|
|
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
</li>
|
|
|
|
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
|
|
|
<button
|
|
|
|
id="nav-spam-check-tab"
|
|
|
|
class="dropdown-item"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-spam-check"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-html"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
Spam Analysis
|
|
|
|
<span
|
|
|
|
v-if="spamScore !== false"
|
|
|
|
class="badge rounded-pill float-end"
|
|
|
|
:class="spamScoreColor"
|
|
|
|
>
|
|
|
|
<small>{{ spamScore }}</small>
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
</li>
|
|
|
|
</ul>
|
|
|
|
</div>
|
|
|
|
<button
|
|
|
|
v-if="mailbox.showHTMLCheck && message.HTML != ''"
|
|
|
|
id="nav-html-check-tab"
|
|
|
|
class="d-none d-xl-inline-block nav-link position-relative"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-html-check"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-html"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
HTML Check
|
|
|
|
<span v-if="htmlScore !== false" class="badge rounded-pill p-1" :class="htmlScoreColor">
|
|
|
|
<small>{{ Math.floor(htmlScore) }}%</small>
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
v-if="mailbox.showLinkCheck"
|
|
|
|
id="nav-link-check-tab"
|
|
|
|
class="d-none d-xl-inline-block nav-link"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-link-check"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-link-check"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
Link Check
|
|
|
|
<i v-if="linkCheckErrors === 0" class="bi bi-check-all text-success"></i>
|
|
|
|
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger">
|
|
|
|
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
|
|
|
|
id="nav-spam-check-tab"
|
|
|
|
class="d-none d-xl-inline-block nav-link position-relative"
|
|
|
|
data-bs-toggle="tab"
|
|
|
|
data-bs-target="#nav-spam-check"
|
|
|
|
type="button"
|
|
|
|
role="tab"
|
|
|
|
aria-controls="nav-html"
|
|
|
|
aria-selected="false"
|
|
|
|
>
|
|
|
|
Spam Analysis
|
|
|
|
<span v-if="spamScore !== false" class="badge rounded-pill" :class="spamScoreColor">
|
|
|
|
<small>{{ spamScore }}</small>
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<div v-if="showMobileButtons" class="d-none d-lg-block ms-auto me-3">
|
|
|
|
<template v-for="(_, key) in responsiveSizes" :key="'responsive_' + key">
|
|
|
|
<button
|
|
|
|
class="btn"
|
|
|
|
:disabled="scaleHTMLPreview == key"
|
|
|
|
:title="'Switch to ' + key + ' view'"
|
|
|
|
@click="scaleHTMLPreview = key"
|
|
|
|
>
|
|
|
|
<i class="bi" :class="'bi-' + key"></i>
|
|
|
|
</button>
|
|
|
|
</template>
|
|
|
|
</div>
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
<div id="nav-tabContent" class="tab-content mb-5">
|
|
|
|
<div
|
|
|
|
v-if="message.HTML != ''"
|
|
|
|
id="nav-html"
|
|
|
|
class="tab-pane fade show"
|
|
|
|
role="tabpanel"
|
|
|
|
aria-labelledby="nav-html-tab"
|
|
|
|
tabindex="0"
|
|
|
|
>
|
|
|
|
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
|
|
|
<iframe
|
|
|
|
id="preview-html"
|
|
|
|
target-blank=""
|
|
|
|
class="tab-pane d-block"
|
|
|
|
:srcdoc="sanitizedHTML"
|
|
|
|
frameborder="0"
|
|
|
|
style="width: 100%; height: 100%; background: #fff"
|
|
|
|
@load="resizeIframe"
|
|
|
|
>
|
|
|
|
</iframe>
|
|
|
|
</div>
|
|
|
|
<Attachments
|
|
|
|
v-if="allAttachments(message).length"
|
|
|
|
:message="message"
|
|
|
|
:attachments="allAttachments(message)"
|
|
|
|
>
|
|
|
|
</Attachments>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
v-if="message.HTML"
|
|
|
|
id="nav-html-source"
|
|
|
|
class="tab-pane fade"
|
|
|
|
role="tabpanel"
|
|
|
|
aria-labelledby="nav-html-source-tab"
|
|
|
|
tabindex="0"
|
|
|
|
>
|
|
|
|
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
id="nav-plain-text"
|
|
|
|
class="tab-pane fade"
|
|
|
|
role="tabpanel"
|
|
|
|
aria-labelledby="nav-plain-text-tab"
|
|
|
|
tabindex="0"
|
|
|
|
:class="message.HTML == '' ? 'show' : ''"
|
|
|
|
>
|
|
|
|
<!-- eslint-disable vue/no-v-html -->
|
|
|
|
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
|
|
|
<!-- -eslint-disable vue/no-v-html -->
|
|
|
|
<Attachments
|
|
|
|
v-if="allAttachments(message).length"
|
|
|
|
:message="message"
|
|
|
|
:attachments="allAttachments(message)"
|
|
|
|
>
|
|
|
|
</Attachments>
|
|
|
|
</div>
|
|
|
|
<div id="nav-headers" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
|
|
|
<Headers v-if="loadHeaders" :message="message"></Headers>
|
|
|
|
</div>
|
|
|
|
<div id="nav-raw" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
|
|
|
<iframe
|
|
|
|
v-if="srcURI"
|
|
|
|
:src="srcURI"
|
|
|
|
frameborder="0"
|
|
|
|
style="width: 100%; height: 300px"
|
|
|
|
@load="initRawIframe"
|
|
|
|
></iframe>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
id="nav-html-check"
|
|
|
|
class="tab-pane fade"
|
|
|
|
role="tabpanel"
|
|
|
|
aria-labelledby="nav-html-check-tab"
|
|
|
|
tabindex="0"
|
|
|
|
>
|
|
|
|
<HTMLCheck
|
|
|
|
v-if="mailbox.showHTMLCheck && message.HTML != ''"
|
|
|
|
:message="message"
|
|
|
|
@set-html-score="(n) => (htmlScore = n)"
|
|
|
|
@set-badge-style="(v) => (htmlScoreColor = v)"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
|
|
|
|
id="nav-spam-check"
|
|
|
|
class="tab-pane fade"
|
|
|
|
role="tabpanel"
|
|
|
|
aria-labelledby="nav-spam-check-tab"
|
|
|
|
tabindex="0"
|
|
|
|
>
|
|
|
|
<SpamAssassin
|
|
|
|
:message="message"
|
|
|
|
@set-spam-score="(n) => (spamScore = n)"
|
|
|
|
@set-badge-style="(v) => (spamScoreColor = v)"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
v-if="mailbox.showLinkCheck"
|
|
|
|
id="nav-link-check"
|
|
|
|
class="tab-pane fade"
|
|
|
|
role="tabpanel"
|
|
|
|
aria-labelledby="nav-html-check-tab"
|
|
|
|
tabindex="0"
|
|
|
|
>
|
|
|
|
<LinkCheck :message="message" @set-link-errors="(n) => (linkCheckErrors = n)" />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|