mirror of
https://github.com/axllent/mailpit.git
synced 2025-07-15 01:25:10 +02:00
Chore: Apply linting to all JavaScript/Vue files with eslint & prettier
This commit is contained in:
@ -1,13 +1,16 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Number,
|
||||
loading: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loader" v-if="loading > 0">
|
||||
<div v-if="loading > 0" class="loader">
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="spinner-border text-muted" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
|
@ -1,75 +1,83 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import Settings from '../components/Settings.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import Settings from "./AppSettings.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
Settings,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadInfo() {
|
||||
this.get(this.resolve('/api/v1/info'), false, (response) => {
|
||||
mailbox.appInfo = response.data
|
||||
this.modal('AppInfoModal').show()
|
||||
})
|
||||
this.get(this.resolve("/api/v1/info"), false, (response) => {
|
||||
mailbox.appInfo = response.data;
|
||||
this.modal("AppInfoModal").show();
|
||||
});
|
||||
},
|
||||
|
||||
requestNotifications() {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notifications")
|
||||
alert("This browser does not support desktop notifications");
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
mailbox.notificationsEnabled = true
|
||||
mailbox.notificationsEnabled = true;
|
||||
}
|
||||
|
||||
this.modal('EnableNotificationsModal').hide()
|
||||
})
|
||||
this.modal("EnableNotificationsModal").hide();
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo()">
|
||||
<button class="text-muted btn btn-sm" @click="loadInfo()">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary float-end"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal"
|
||||
title="Mailpit UI settings"
|
||||
>
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled">
|
||||
<button
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled"
|
||||
class="btn btn-sm btn-outline-secondary float-end me-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal"
|
||||
title="Enable browser notifications"
|
||||
>
|
||||
<i class="bi bi-bell"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -77,12 +85,17 @@ export default {
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AppInfoModal"
|
||||
class="modal modal-xl fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" v-if="mailbox.appInfo.RuntimeStats">
|
||||
<div v-if="mailbox.appInfo.RuntimeStats" class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="AppInfoModalLabel">
|
||||
<h5 id="AppInfoModalLabel" class="modal-title">
|
||||
Mailpit
|
||||
<code>({{ mailbox.appInfo.Version }})</code>
|
||||
</h5>
|
||||
@ -92,19 +105,27 @@ export default {
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6">
|
||||
<div v-if="mailbox.appInfo.LatestVersion != 'disabled'">
|
||||
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''">
|
||||
<div v-if="mailbox.appInfo.LatestVersion == ''" class="row g-3">
|
||||
<div class="col">
|
||||
<div class="alert alert-warning mb-3">
|
||||
There might be a newer version available. The check failed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3"
|
||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
|
||||
<div
|
||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
|
||||
class="row g-3"
|
||||
>
|
||||
<div class="col">
|
||||
<a class="btn btn-warning d-block mb-3"
|
||||
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
|
||||
<a
|
||||
class="btn btn-warning d-block mb-3"
|
||||
:href="
|
||||
'https://github.com/axllent/mailpit/releases/tag/' +
|
||||
mailbox.appInfo.LatestVersion
|
||||
"
|
||||
>
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
|
||||
available.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -117,15 +138,21 @@ export default {
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit"
|
||||
target="_blank">
|
||||
<a
|
||||
class="btn btn-primary w-100"
|
||||
href="https://github.com/axllent/mailpit"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="bi bi-github"></i>
|
||||
Github
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://mailpit.axllent.org/docs/"
|
||||
target="_blank">
|
||||
<a
|
||||
class="btn btn-primary w-100"
|
||||
href="https://mailpit.axllent.org/docs/"
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
@ -133,7 +160,8 @@ export default {
|
||||
<div class="card border-secondary text-center">
|
||||
<div class="card-header">Database size</div>
|
||||
<div class="card-body text-muted">
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||
<h5 class="card-title">
|
||||
{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
@ -154,8 +182,7 @@ export default {
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
v-on:click="loadInfo()">
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" @click="loadInfo()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@ -163,46 +190,38 @@ export default {
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Mailpit up since
|
||||
</td>
|
||||
<td>Mailpit up since</td>
|
||||
<td>
|
||||
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Messages deleted
|
||||
</td>
|
||||
<td>Messages deleted</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages accepted
|
||||
</td>
|
||||
<td>SMTP messages accepted</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||
<small class="text-muted">
|
||||
({{
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
getFileSize(
|
||||
mailbox.appInfo.RuntimeStats.SMTPAcceptedSize,
|
||||
)
|
||||
}})
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages rejected
|
||||
</td>
|
||||
<td>SMTP messages rejected</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="mailbox.uiConfig.DuplicatesIgnored">
|
||||
<td>
|
||||
SMTP messages ignored
|
||||
</td>
|
||||
<td>SMTP messages ignored</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}
|
||||
</td>
|
||||
@ -210,12 +229,9 @@ export default {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@ -224,26 +240,30 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1"
|
||||
aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
||||
<div
|
||||
id="EnableNotificationsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="EnableNotificationsModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<h5 id="EnableNotificationsModalLabel" class="modal-title">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click
|
||||
<code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the
|
||||
notifications.
|
||||
<code>enable notifications</code>, and that you must have Mailpit open in a browser tab to
|
||||
be able to receive the notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" v-on:click="requestNotifications">
|
||||
<button type="button" class="btn btn-success" @click="requestNotifications">
|
||||
Enable notifications
|
||||
</button>
|
||||
</div>
|
@ -1,59 +1,57 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox.js'
|
||||
import { mailbox } from "../stores/mailbox.js";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
updating: false,
|
||||
needsUpdate: false,
|
||||
timeout: 500,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
updating: false,
|
||||
needsUpdate: false,
|
||||
timeout: 500,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
mailboxUnread() {
|
||||
return mailbox.unread
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
mailboxUnread() {
|
||||
return mailbox.unread;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
mailboxUnread: {
|
||||
handler() {
|
||||
if (this.updating) {
|
||||
this.needsUpdate = true
|
||||
return
|
||||
}
|
||||
watch: {
|
||||
mailboxUnread: {
|
||||
handler() {
|
||||
if (this.updating) {
|
||||
this.needsUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleUpdate()
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
scheduleUpdate() {
|
||||
this.updating = true
|
||||
this.needsUpdate = false
|
||||
methods: {
|
||||
scheduleUpdate() {
|
||||
this.updating = true;
|
||||
this.needsUpdate = false;
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.updateAppBadge()
|
||||
this.updating = false
|
||||
window.setTimeout(() => {
|
||||
this.updateAppBadge();
|
||||
this.updating = false;
|
||||
|
||||
if (this.needsUpdate) {
|
||||
this.scheduleUpdate()
|
||||
}
|
||||
}, this.timeout)
|
||||
},
|
||||
if (this.needsUpdate) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}, this.timeout);
|
||||
},
|
||||
|
||||
updateAppBadge() {
|
||||
if (!('setAppBadge' in navigator)) {
|
||||
return
|
||||
}
|
||||
updateAppBadge() {
|
||||
if (!("setAppBadge" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.setAppBadge(this.mailboxUnread)
|
||||
}
|
||||
}
|
||||
}
|
||||
navigator.setAppBadge(this.mailboxUnread);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
|
116
server/ui-src/components/AppFavicon.vue
Normal file
116
server/ui-src/components/AppFavicon.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<script>
|
||||
import { mailbox } from "../stores/mailbox.js";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
favicon: false,
|
||||
iconPath: false,
|
||||
iconTextColor: "#ffffff",
|
||||
iconBgColor: "#dd0000",
|
||||
iconFontSize: 40,
|
||||
iconProcessing: false,
|
||||
iconTimeout: 500,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
count() {
|
||||
let i = mailbox.unread;
|
||||
if (i > 1000) {
|
||||
i = Math.floor(i / 1000) + "k";
|
||||
}
|
||||
|
||||
return i;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
count() {
|
||||
if (!this.favicon || this.iconProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconProcessing = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.icoUpdate();
|
||||
}, this.iconTimeout);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.favicon = document.head.querySelector('link[rel="icon"]');
|
||||
if (this.favicon) {
|
||||
this.iconPath = this.favicon.href;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async icoUpdate() {
|
||||
if (!this.favicon) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.count) {
|
||||
this.iconProcessing = false;
|
||||
this.favicon.href = this.iconPath;
|
||||
return;
|
||||
}
|
||||
|
||||
let fontSize = this.iconFontSize;
|
||||
// Draw badge text
|
||||
let textPaddingX = 7;
|
||||
const textPaddingY = 3;
|
||||
|
||||
const strlen = this.count.toString().length;
|
||||
|
||||
if (strlen > 2) {
|
||||
// if text >= 3 characters then reduce size and padding
|
||||
textPaddingX = 4;
|
||||
fontSize = strlen > 3 ? 30 : 36;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// Draw base icon
|
||||
const icon = new Image();
|
||||
icon.src = this.iconPath;
|
||||
await icon.decode();
|
||||
|
||||
ctx.drawImage(icon, 0, 0, 64, 64);
|
||||
|
||||
// Measure text
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`;
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "top";
|
||||
const textMetrics = ctx.measureText(this.count);
|
||||
|
||||
// Draw badge
|
||||
const paddingX = 7;
|
||||
const paddingY = 4;
|
||||
const cornerRadius = 8;
|
||||
|
||||
const width = textMetrics.width + paddingX * 2;
|
||||
const height = fontSize + paddingY * 2;
|
||||
const x = canvas.width - width;
|
||||
const y = canvas.height - height - 1;
|
||||
|
||||
ctx.fillStyle = this.iconBgColor;
|
||||
ctx.roundRect(x, y, width, height, cornerRadius);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = this.iconTextColor;
|
||||
ctx.fillText(this.count, canvas.width - textPaddingX, canvas.height - fontSize - textPaddingY);
|
||||
|
||||
this.iconProcessing = false;
|
||||
|
||||
this.favicon.href = canvas.toDataURL("image/png");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
289
server/ui-src/components/AppNotifications.vue
Normal file
289
server/ui-src/components/AppNotifications.vue
Normal file
@ -0,0 +1,289 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { Toast } from "bootstrap";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
clientErrors: [], // errors received via websocket
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const d = document.getElementById("app");
|
||||
if (d) {
|
||||
this.version = d.dataset.version;
|
||||
}
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`);
|
||||
|
||||
this.socketBreakReset();
|
||||
this.connect();
|
||||
|
||||
mailbox.notificationsSupported =
|
||||
window.isSecureContext && "Notification" in window && Notification.permission !== "denied";
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission === "granted";
|
||||
|
||||
this.errorNotificationCron();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect() {
|
||||
const ws = new WebSocket(this.socketURI);
|
||||
ws.onmessage = (e) => {
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(e.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type === "new" && response.Data) {
|
||||
this.eventBus.emit("new", response.Data);
|
||||
|
||||
for (const i in response.Data.Tags) {
|
||||
if (
|
||||
mailbox.tags.findIndex((e) => {
|
||||
return e.toLowerCase() === response.Data.Tags[i].toLowerCase();
|
||||
}) < 0
|
||||
) {
|
||||
mailbox.tags.push(response.Data.Tags[i]);
|
||||
mailbox.tags.sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!this.pauseNotifications) {
|
||||
this.pauseNotifications = true;
|
||||
const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]";
|
||||
this.browserNotify("New mail from: " + from, response.Data.Subject);
|
||||
this.setMessageToast(response.Data);
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => {
|
||||
this.pauseNotifications = false;
|
||||
}, 2000);
|
||||
}
|
||||
} else if (response.Type === "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true;
|
||||
mailbox.refresh = true; // trigger refresh
|
||||
window.setTimeout(() => {
|
||||
mailbox.refresh = false;
|
||||
}, 500);
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type === "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total;
|
||||
mailbox.unread = response.Data.Unread;
|
||||
|
||||
// detect version updated, refresh is needed
|
||||
if (this.version !== response.Data.Version) {
|
||||
location.reload();
|
||||
}
|
||||
} else if (response.Type === "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data);
|
||||
} else if (response.Type === "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data);
|
||||
} else if (response.Type === "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate");
|
||||
} else if (response.Type === "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
mailbox.connected = true;
|
||||
this.socketLastConnection = Date.now();
|
||||
if (this.reconnectRefresh) {
|
||||
this.reconnectRefresh = false;
|
||||
mailbox.refresh = true; // trigger refresh
|
||||
window.setTimeout(() => {
|
||||
mailbox.refresh = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (this.socketLastConnection === 0) {
|
||||
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
||||
console.log("Unable to connect to websocket, disabling websocket support");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mailbox.connected) {
|
||||
// count disconnections
|
||||
this.socketBreaks++;
|
||||
}
|
||||
|
||||
// set disconnected state
|
||||
mailbox.connected = false;
|
||||
|
||||
if (this.socketBreaks > 3) {
|
||||
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
||||
// something is not working right on their end, see issue #319
|
||||
console.log("Unstable websocket connection, disabling websocket support");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - this.socketLastConnection > 5000) {
|
||||
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
||||
this.reconnectRefresh = true;
|
||||
} else {
|
||||
this.reconnectRefresh = false;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect(); // reconnect
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
ws.close();
|
||||
};
|
||||
},
|
||||
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0;
|
||||
this.socketBreakReset();
|
||||
}, 15000);
|
||||
},
|
||||
|
||||
browserNotify(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
const options = {
|
||||
body: message,
|
||||
icon: this.resolve("/notification.png"),
|
||||
};
|
||||
|
||||
(() => new Notification(title, options))();
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast(m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastMessage = m;
|
||||
|
||||
const el = document.getElementById("messageToast");
|
||||
if (el) {
|
||||
el.addEventListener("hidden.bs.toast", () => {
|
||||
this.toastMessage = false;
|
||||
});
|
||||
|
||||
Toast.getOrCreateInstance(el).show();
|
||||
}
|
||||
},
|
||||
|
||||
closeToast() {
|
||||
const el = document.getElementById("messageToast");
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide();
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000; // expire after 5s
|
||||
this.clientErrors.push(d);
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
this.errorNotificationCron();
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div
|
||||
v-for="(error, i) in clientErrors"
|
||||
:key="'error_' + i"
|
||||
class="toast show"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="toast-header">
|
||||
<svg
|
||||
class="bd-placeholder-img rounded me-2"
|
||||
width="20"
|
||||
height="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
focusable="false"
|
||||
>
|
||||
<rect width="100%" height="100%" :fill="error.Level === 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div v-if="toastMessage" class="toast-header">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink
|
||||
:to="'/view/' + toastMessage.ID"
|
||||
class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast"
|
||||
>
|
||||
<template v-if="toastMessage.Subject !== ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else> [ no subject ] </template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
381
server/ui-src/components/AppSettings.vue
Normal file
381
server/ui-src/components/AppSettings.vue
Normal file
@ -0,0 +1,381 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import Tags from "bootstrap5-tags";
|
||||
import timezones from "timezones-list";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: localStorage.getItem("theme") ? localStorage.getItem("theme") : "auto",
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(v) {
|
||||
if (v === "auto") {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
localStorage.setItem("theme", v);
|
||||
}
|
||||
this.setTheme();
|
||||
},
|
||||
|
||||
chaosConfig: {
|
||||
handler() {
|
||||
this.chaosUpdated = true;
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
|
||||
"mailbox.skipConfirmations"(v) {
|
||||
if (v) {
|
||||
localStorage.setItem("skip-confirmations", "true");
|
||||
} else {
|
||||
localStorage.removeItem("skip-confirmations");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme();
|
||||
this.$nextTick(() => {
|
||||
Tags.init("select.tz");
|
||||
});
|
||||
|
||||
mailbox.skipConfirmations = !!localStorage.getItem("skip-confirmations");
|
||||
},
|
||||
|
||||
methods: {
|
||||
setTheme() {
|
||||
if (this.theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-bs-theme", this.theme);
|
||||
}
|
||||
},
|
||||
|
||||
loadChaos() {
|
||||
this.get(this.resolve("/api/v1/chaos"), null, (response) => {
|
||||
this.chaosConfig = response.data;
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
saveChaos() {
|
||||
this.put(this.resolve("/api/v1/chaos"), this.chaosConfig, (response) => {
|
||||
this.chaosConfig = response.data;
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="SettingsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="SettingsModalLabel"
|
||||
aria-hidden="true"
|
||||
data-bs-keyboard="false"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 id="SettingsModalLabel" class="modal-title">Mailpit settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul v-if="mailbox.uiConfig.ChaosEnabled" id="myTab" class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
id="ui-tab"
|
||||
class="nav-link active"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#ui-tab-pane"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="ui-tab-pane"
|
||||
aria-selected="true"
|
||||
>
|
||||
Web UI
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
id="chaos-tab"
|
||||
class="nav-link"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#chaos-tab-pane"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="chaos-tab-pane"
|
||||
aria-selected="false"
|
||||
@click="loadChaos"
|
||||
>
|
||||
Chaos
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div
|
||||
id="ui-tab-pane"
|
||||
class="tab-pane fade show active"
|
||||
role="tabpanel"
|
||||
aria-labelledby="ui-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="my-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select id="theme" v-model="theme" class="form-select">
|
||||
<option value="auto">Auto (detect from browser)</option>
|
||||
<option value="light">Light theme</option>
|
||||
<option value="dark">Dark theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||
<select
|
||||
id="timezone"
|
||||
v-model="mailbox.timeZone"
|
||||
class="form-select tz"
|
||||
data-allow-same="true"
|
||||
>
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :key="t" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="tagColors"
|
||||
v-model="mailbox.showTagColors"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="tagColors">
|
||||
Use auto-generated tag colors
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="htmlCheck">
|
||||
Show HTML check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mailbox.uiConfig.SpamAssassin" class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="spamCheck">
|
||||
Show spam check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="skip-confirmations"
|
||||
v-model="mailbox.skipConfirmations"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="skip-confirmations">
|
||||
Skip
|
||||
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
||||
<code>Delete all</code> &
|
||||
</template>
|
||||
<code>Mark all read</code> confirmation dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="mailbox.uiConfig.ChaosEnabled"
|
||||
id="chaos-tab-pane"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="chaos-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<p class="my-3">
|
||||
<b>Chaos</b> allows you to set random SMTP failures and response codes at various stages
|
||||
in a SMTP transaction to test application resilience (<a
|
||||
href="https://mailpit.axllent.org/docs/integration/chaos/"
|
||||
target="_blank"
|
||||
>
|
||||
see documentation </a
|
||||
>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>Response code</code> is the SMTP error code returned by the server if this
|
||||
error is triggered. Error codes must range between 400 and 599.
|
||||
</li>
|
||||
<li>
|
||||
<code>Error probability</code> is the % chance that the error will occur per message
|
||||
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
||||
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
||||
messages received.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="chaosConfig">
|
||||
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Sender</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the sender (From / Sender).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label"> Response code </label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Sender.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Sender.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Recipient</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the recipients (To, Cc, Bcc).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label"> Response code </label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Recipient.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Recipient.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Authentication</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an authentication error response. Note that SMTP authentication must
|
||||
be configured too.
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label"> Response code </label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Authentication.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Authentication.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chaosUpdated" class="mb-3 text-center">
|
||||
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
@ -9,74 +9,83 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
editableTags: [],
|
||||
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/),
|
||||
validTagRe: /^([a-zA-Z0-9\- ._]){1,}$/,
|
||||
tagToDelete: false,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
'mailbox.tags': {
|
||||
"mailbox.tags": {
|
||||
handler(tags) {
|
||||
this.editableTags = []
|
||||
this.editableTags = [];
|
||||
tags.forEach((t) => {
|
||||
this.editableTags.push({ before: t, after: t })
|
||||
})
|
||||
this.editableTags.push({ before: t, after: t });
|
||||
});
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
validTag(t) {
|
||||
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) {
|
||||
return false
|
||||
if (!t.after.match(/^([a-zA-Z0-9\- _.]){1,}$/)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = t.after.toLowerCase()
|
||||
const lower = t.after.toLowerCase();
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) {
|
||||
return false
|
||||
if (this.editableTags[x].before !== t.before && lower === this.editableTags[x].before.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
},
|
||||
|
||||
renameTag(t) {
|
||||
if (!this.validTag(t) || t.before == t.after) {
|
||||
return
|
||||
if (!this.validTag(t) || t.before === t.after) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
|
||||
// the API triggers a reload via websockets
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
deleteTag() {
|
||||
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
|
||||
// the API triggers a reload via websockets
|
||||
this.tagToDelete = false
|
||||
})
|
||||
this.tagToDelete = false;
|
||||
});
|
||||
},
|
||||
|
||||
resetTagEdit(t) {
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) {
|
||||
this.editableTags[x].after = this.editableTags[x].before
|
||||
if (
|
||||
this.editableTags[x].before !== t.before &&
|
||||
this.editableTags[x].before !== this.editableTags[x].after
|
||||
) {
|
||||
this.editableTags[x].after = this.editableTags[x].before;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div
|
||||
id="EditTagsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="EditTagsModalLabel"
|
||||
aria-hidden="true"
|
||||
data-bs-keyboard="false"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5>
|
||||
<h5 id="EditTagsModalLabel" class="modal-title">Edit tags</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@ -84,29 +93,34 @@ export default {
|
||||
Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag
|
||||
itself, and not any messages which had the tag.
|
||||
</p>
|
||||
<div class="mb-3" v-for="t in editableTags">
|
||||
<div v-for="(t, i) in editableTags" :key="'tag_' + i" class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''"
|
||||
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required
|
||||
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before"
|
||||
@focus="resetTagEdit(t)">
|
||||
<button v-if="t.before != t.after" class="btn btn-success"
|
||||
@click="renameTag(t)">Save</button>
|
||||
<input
|
||||
v-model.trim="t.after"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:class="!validTag(t) ? 'is-invalid' : ''"
|
||||
aria-describedby="inputGroupPrepend"
|
||||
required
|
||||
@keydown.enter="renameTag(t)"
|
||||
@keydown.esc="t.after = t.before"
|
||||
@focus="resetTagEdit(t)"
|
||||
/>
|
||||
<button v-if="t.before != t.after" class="btn btn-success" @click="renameTag(t)">
|
||||
Save
|
||||
</button>
|
||||
<template v-else>
|
||||
<button class="btn btn-outline-danger"
|
||||
<button
|
||||
class="btn btn-outline-danger"
|
||||
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
|
||||
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false">
|
||||
<template v-if="tagToDelete == t">
|
||||
Confirm?
|
||||
</template>
|
||||
<template v-else>
|
||||
Delete
|
||||
</template>
|
||||
@click="!tagToDelete ? (tagToDelete = t) : deleteTag()"
|
||||
@blur="tagToDelete = false"
|
||||
>
|
||||
<template v-if="tagToDelete == t"> Confirm? </template>
|
||||
<template v-else> Delete </template>
|
||||
</button>
|
||||
</template>
|
||||
<div class="invalid-feedback">
|
||||
Invalid tag name
|
||||
</div>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,122 +0,0 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
favicon: false,
|
||||
iconPath: false,
|
||||
iconTextColor: '#ffffff',
|
||||
iconBgColor: '#dd0000',
|
||||
iconFontSize: 40,
|
||||
iconProcessing: false,
|
||||
iconTimeout: 500,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.favicon = document.head.querySelector('link[rel="icon"]')
|
||||
if (this.favicon) {
|
||||
this.iconPath = this.favicon.href
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
count() {
|
||||
let i = mailbox.unread
|
||||
if (i > 1000) {
|
||||
i = Math.floor(i / 1000) + 'k'
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
count() {
|
||||
if (!this.favicon || this.iconProcessing) {
|
||||
return
|
||||
}
|
||||
|
||||
this.iconProcessing = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.icoUpdate()
|
||||
}, this.iconTimeout)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async icoUpdate() {
|
||||
if (!this.favicon) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.count) {
|
||||
this.iconProcessing = false
|
||||
this.favicon.href = this.iconPath
|
||||
return
|
||||
}
|
||||
|
||||
let fontSize = this.iconFontSize
|
||||
// Draw badge text
|
||||
let textPaddingX = 7
|
||||
let textPaddingY = 3
|
||||
|
||||
let strlen = this.count.toString().length
|
||||
|
||||
if (strlen > 2) {
|
||||
// if text >= 3 characters then reduce size and padding
|
||||
textPaddingX = 4
|
||||
fontSize = strlen > 3 ? 30 : 36
|
||||
}
|
||||
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
|
||||
let ctx = canvas.getContext('2d')
|
||||
|
||||
// Draw base icon
|
||||
let icon = new Image()
|
||||
icon.src = this.iconPath
|
||||
await icon.decode()
|
||||
|
||||
ctx.drawImage(icon, 0, 0, 64, 64)
|
||||
|
||||
// Measure text
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'top'
|
||||
let textMetrics = ctx.measureText(this.count)
|
||||
|
||||
// Draw badge
|
||||
let paddingX = 7
|
||||
let paddingY = 4
|
||||
let cornerRadius = 8
|
||||
|
||||
let width = textMetrics.width + paddingX * 2
|
||||
let height = fontSize + paddingY * 2
|
||||
let x = canvas.width - width
|
||||
let y = canvas.height - height - 1
|
||||
|
||||
ctx.fillStyle = this.iconBgColor
|
||||
ctx.roundRect(x, y, width, height, cornerRadius)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = this.iconTextColor
|
||||
ctx.fillText(
|
||||
this.count,
|
||||
canvas.width - textPaddingX,
|
||||
canvas.height - fontSize - textPaddingY
|
||||
)
|
||||
|
||||
this.iconProcessing = false
|
||||
|
||||
this.favicon.href = canvas.toDataURL("image/png")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template></template>
|
@ -1,135 +1,142 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import dayjs from 'dayjs'
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import dayjs from "dayjs";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
CommonMixins
|
||||
],
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins
|
||||
// use different name to `loading` as that is already in use in CommonMixins
|
||||
loadingMessages: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
const relativeTime = require("dayjs/plugin/relativeTime");
|
||||
dayjs.extend(relativeTime);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refreshUI()
|
||||
this.refreshUI();
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshUI() {
|
||||
window.setTimeout(() => {
|
||||
this.$forceUpdate()
|
||||
this.refreshUI()
|
||||
}, 30000)
|
||||
this.$forceUpdate();
|
||||
this.refreshUI();
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
getRelativeCreated(message) {
|
||||
const d = new Date(message.Created)
|
||||
return dayjs(d).fromNow()
|
||||
const d = new Date(message.Created);
|
||||
return dayjs(d).fromNow();
|
||||
},
|
||||
|
||||
getPrimaryEmailTo(message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
if (message.To && message.To.length > 0) {
|
||||
return message.To[0].Address;
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
return "[ Undisclosed recipients ]";
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
return mailbox.selected.indexOf(id) !== -1;
|
||||
},
|
||||
|
||||
toggleSelected(e, id) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
if (this.isSelected(id)) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||
return ele !== id;
|
||||
});
|
||||
} else {
|
||||
mailbox.selected.push(id)
|
||||
mailbox.selected.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
selectRange(e, id) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
let selecting = false
|
||||
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]
|
||||
if (lastSelected == id) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
return
|
||||
let selecting = false;
|
||||
const lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1];
|
||||
if (lastSelected === id) {
|
||||
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||
return ele !== id;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastSelected === false) {
|
||||
mailbox.selected.push(id)
|
||||
return
|
||||
mailbox.selected.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let d of mailbox.messages) {
|
||||
for (const d of mailbox.messages) {
|
||||
if (selecting) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
mailbox.selected.push(d.ID);
|
||||
}
|
||||
if (d.ID == lastSelected || d.ID == id) {
|
||||
if (d.ID === lastSelected || d.ID === id) {
|
||||
// reached backwards select
|
||||
break
|
||||
break;
|
||||
}
|
||||
} else if (d.ID == id || d.ID == lastSelected) {
|
||||
} else if (d.ID === id || d.ID === lastSelected) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
mailbox.selected.push(d.ID);
|
||||
}
|
||||
selecting = true
|
||||
selecting = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
t = `"${t}"`;
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
q: "tag:" + t,
|
||||
};
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
const params = new URLSearchParams(p);
|
||||
return "/search?" + params.toString();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID"
|
||||
<RouterLink
|
||||
v-for="message in mailbox.messages"
|
||||
:id="message.ID"
|
||||
:key="'message_' + message.ID"
|
||||
:to="'/view/' + message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
|
||||
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
|
||||
@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)"
|
||||
@click.shift="selectRange($event, message.ID)">
|
||||
:class="[message.Read ? 'read' : '', isSelected(message.ID) ? ' selected' : '']"
|
||||
@click.meta="toggleSelected($event, message.ID)"
|
||||
@click.ctrl="toggleSelected($event, message.ID)"
|
||||
@click.shift="selectRange($event, message.ID)"
|
||||
>
|
||||
<div class="col-lg-3">
|
||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
<i v-if="message.Attachments" class="bi bi-paperclip h6 me-1"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div v-if="message.From" class="overflow-x-hidden">
|
||||
@ -142,30 +149,37 @@ export default {
|
||||
<div class="overflow-x-hidden">
|
||||
<div class="text-truncate text-muted small privacy">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
<span v-if="message.To && message.To.length > 1"> [+{{ message.To.length - 1 }}] </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||
<div class="subject text-truncate text-spaces-nowrap">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
<b>{{ message.Subject !== "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div v-if="message.Snippet != ''" class="small text-muted text-truncate">
|
||||
<div v-if="message.Snippet !== ''" class="small text-muted text-truncate">
|
||||
{{ message.Snippet }}
|
||||
</div>
|
||||
<div v-if="message.Tags.length">
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
||||
v-on:click="pagination.start = 0"
|
||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||
:title="'Filter messages tagged with ' + t">
|
||||
<RouterLink
|
||||
v-for="t in message.Tags"
|
||||
:key="t"
|
||||
class="badge me-1"
|
||||
:to="toTagUrl(t)"
|
||||
:style="
|
||||
mailbox.showTagColors
|
||||
? { backgroundColor: colorHash(t) }
|
||||
: { backgroundColor: '#6c757d' }
|
||||
"
|
||||
:title="'Filter messages tagged with ' + t"
|
||||
@click="pagination.start = 0"
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||
<i v-if="message.Attachments" class="bi bi-paperclip float-start h6"></i>
|
||||
{{ getFileSize(message.Size) }}
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
|
||||
@ -176,10 +190,10 @@ export default {
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-center mt-5">
|
||||
<span v-if="loadingMessages > 0" class="text-muted">
|
||||
Loading messages...
|
||||
</span>
|
||||
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
|
||||
<span v-if="loadingMessages > 0" class="text-muted"> Loading messages... </span>
|
||||
<template v-else-if="getSearch()"
|
||||
>No results for <code>{{ getSearch() }}</code></template
|
||||
>
|
||||
<template v-else>No messages in your mailbox</template>
|
||||
</p>
|
||||
</template>
|
||||
|
@ -1,156 +1,193 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from "./AjaxLoader.vue"
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import NavSelected from "../components/NavSelected.vue";
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
reloadInbox() {
|
||||
const paginationParams = this.getPaginationParams()
|
||||
const reload = paginationParams?.start ? false : true
|
||||
const paginationParams = this.getPaginationParams();
|
||||
const reload = !paginationParams?.start;
|
||||
|
||||
this.$router.push('/')
|
||||
this.$router.push("/");
|
||||
if (reload) {
|
||||
// already on first page, reload messages
|
||||
this.loadMessages()
|
||||
this.loadMessages();
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages() {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
this.hideNav(); // hide mobile menu
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.put(this.resolve(`/api/v1/messages`), { read: true }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
deleteAllMessages() {
|
||||
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
|
||||
pagination.start = 0
|
||||
this.loadMessages()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
pagination.start = 0;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
|
||||
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
|
||||
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
|
||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||
<button class="list-group-item list-group-item-action active" @click="reloadInbox">
|
||||
<i v-if="mailbox.connected" class="bi bi-envelope-fill me-1"></i>
|
||||
<i v-else class="bi bi-arrow-clockwise me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
<span
|
||||
v-if="mailbox.unread"
|
||||
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||
title="Unread messages"
|
||||
>
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
@click="markAllRead"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
||||
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.total" @click="deleteAllMessages">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.total"
|
||||
@click="deleteAllMessages"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal"
|
||||
:disabled="!mailbox.total"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
<NavSelected @load-messages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="MarkAllReadModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
|
||||
<h5 id="MarkAllReadModalLabel" class="modal-title">Mark all messages as read?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(mailbox.unread) }}
|
||||
message<span v-if="mailbox.unread > 1">s</span> as read.
|
||||
This will mark {{ formatNumber(mailbox.unread) }} message<span v-if="mailbox.unread > 1"
|
||||
>s</span
|
||||
>
|
||||
as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markAllRead">Confirm</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="DeleteAllModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.total) }}
|
||||
message<span v-if="mailbox.total > 1">s</span>.
|
||||
This will permanently delete {{ formatNumber(mailbox.total) }} message<span
|
||||
v-if="mailbox.total > 1"
|
||||
>s</span
|
||||
>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
120
server/ui-src/components/NavPagination.vue
Normal file
120
server/ui-src/components/NavPagination.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { limitOptions, pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
limitOptions,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev() {
|
||||
return pagination.start > 0;
|
||||
},
|
||||
|
||||
canNext() {
|
||||
return this.total > pagination.start + mailbox.messages.length;
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages() {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10);
|
||||
if (t > this.total) {
|
||||
t = this.total;
|
||||
}
|
||||
|
||||
return t;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit() {
|
||||
pagination.start = 0;
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
viewNext() {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10);
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
viewPrev() {
|
||||
let s = pagination.start - pagination.limit;
|
||||
if (s < 0) {
|
||||
s = 0;
|
||||
}
|
||||
pagination.start = s;
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
updateQueryParams() {
|
||||
const path = this.$route.path;
|
||||
const p = {
|
||||
...this.$route.query,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString();
|
||||
} else {
|
||||
delete p.start;
|
||||
}
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
} else {
|
||||
delete p.limit;
|
||||
}
|
||||
const params = new URLSearchParams(p);
|
||||
this.$router.push(path + "?" + params.toString());
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<select
|
||||
v-model="pagination.limit"
|
||||
class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0"
|
||||
@change="changeLimit"
|
||||
>
|
||||
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-light ms-2 me-1"
|
||||
:disabled="!canPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'"
|
||||
@click="viewPrev"
|
||||
>
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-light"
|
||||
:disabled="!canNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'"
|
||||
@click="viewNext"
|
||||
>
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
@ -1,79 +1,79 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import NavSelected from "../components/NavSelected.vue";
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
this.hideNav(); // hide mobile menu
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
deleteAllMessages() {
|
||||
const s = this.getSearch()
|
||||
const s = this.getSearch();
|
||||
if (!s) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
uri += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
let uri = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
|
||||
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||
uri += "&tz=" + encodeURIComponent(mailbox.timeZone);
|
||||
}
|
||||
|
||||
this.delete(uri, false, () => {
|
||||
this.$router.push('/')
|
||||
})
|
||||
this.$router.push("/");
|
||||
});
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
const s = this.getSearch()
|
||||
const s = this.getSearch();
|
||||
if (!s) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = this.resolve(`/api/v1/messages`)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
uri += '?tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
let uri = this.resolve(`/api/v1/messages`);
|
||||
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||
uri += "?tz=" + encodeURIComponent(mailbox.timeZone);
|
||||
}
|
||||
|
||||
this.put(uri, { 'read': true, "search": s }, () => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.put(uri, { read: true, search: s }, () => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
|
||||
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
@ -83,83 +83,121 @@ export default {
|
||||
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
<span
|
||||
v-if="mailbox.unread"
|
||||
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||
title="Unread messages"
|
||||
>
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
@click="markAllRead"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
||||
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
@click="deleteAllMessages" :disabled="!mailbox.count">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.count"
|
||||
@click="deleteAllMessages"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal"
|
||||
:disabled="!mailbox.count"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
<NavSelected @load-messages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="MarkAllReadModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all search results as read?</h5>
|
||||
<h5 id="MarkAllReadModalLabel" class="modal-title">Mark all search results as read?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(mailbox.messages_unread) }}
|
||||
message<span v-if="mailbox.messages_unread > 1">s</span>
|
||||
This will mark {{ formatNumber(mailbox.messages_unread) }} message<span
|
||||
v-if="mailbox.messages_unread > 1"
|
||||
>s</span
|
||||
>
|
||||
matching <code>{{ getSearch() }}</code>
|
||||
as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markAllRead">Confirm</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="DeleteAllModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages matching search?</h5>
|
||||
<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages matching search?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }}
|
||||
message<span v-if="mailbox.count > 1">s</span> matching
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }} message<span
|
||||
v-if="mailbox.count > 1"
|
||||
>s</span
|
||||
>
|
||||
matching
|
||||
<code>{{ getSearch() }}</code>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,118 +1,124 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
// mark selected messages as read
|
||||
markSelectedRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
},
|
||||
// mark selected messages as read
|
||||
markSelectedRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { Read: true, IDs: mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
},
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) !== -1;
|
||||
},
|
||||
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
},
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { Read: false, IDs: mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages() {
|
||||
let ids = []
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected))
|
||||
if (!ids.length) {
|
||||
return false
|
||||
}
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages() {
|
||||
let ids = [];
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected));
|
||||
if (!ids.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
},
|
||||
this.delete(this.resolve(`/api/v1/messages`), { IDs: ids }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
for (const i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// test of any selected emails are read
|
||||
selectedHasRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
// test of any selected emails are read
|
||||
selectedHasRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
for (const i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
|
||||
v-on:click="markSelectedRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark read
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
|
||||
v-on:click="markSelectedUnread">
|
||||
<i class="bi bi-eye-slash me-1"></i>
|
||||
Mark unread
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages()">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancel selection
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="mailbox.selected.length">
|
||||
<button
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!selectedHasUnread()"
|
||||
@click="markSelectedRead"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark read
|
||||
</button>
|
||||
<button
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!selectedHasRead()"
|
||||
@click="markSelectedUnread"
|
||||
>
|
||||
<i class="bi bi-eye-slash me-1"></i>
|
||||
Mark unread
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" @click="deleteMessages()">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" @click="mailbox.selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancel selection
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
@ -10,79 +10,77 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// test whether a tag is currently being searched for (in the URL)
|
||||
inSearch(tag) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const query = urlParams.get('q')
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const query = urlParams.get("q");
|
||||
if (!query) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i')
|
||||
return query.match(re)
|
||||
const re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, "i");
|
||||
return query.match(re);
|
||||
},
|
||||
|
||||
// toggle a tag search in the search URL, add or remove it accordingly
|
||||
toggleTag(e, tag) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
let query = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let query = urlParams.get("q") ? urlParams.get("q") : "";
|
||||
|
||||
let re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, 'i')
|
||||
const re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, "i");
|
||||
|
||||
if (query.match(re)) {
|
||||
// remove is exists
|
||||
query = query.replace(re, '$1$4')
|
||||
query = query.replace(re, "$1$4");
|
||||
} else {
|
||||
// add to query
|
||||
if (tag.match(/ /)) {
|
||||
tag = `"${tag}"`
|
||||
tag = `"${tag}"`;
|
||||
}
|
||||
query = query + " tag:" + tag
|
||||
query = query + " tag:" + tag;
|
||||
}
|
||||
|
||||
query = query.trim()
|
||||
query = query.trim();
|
||||
|
||||
if (query == '') {
|
||||
this.$router.push('/')
|
||||
if (query === "") {
|
||||
this.$router.push("/");
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
start: pagination.start.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
})
|
||||
this.$router.push('/search?' + params.toString())
|
||||
});
|
||||
this.$router.push("/search?" + params.toString());
|
||||
}
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
t = `"${t}"`;
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
q: "tag:" + t,
|
||||
};
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
const params = new URLSearchParams(p);
|
||||
return "/search?" + params.toString();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||
<div class="mt-4 text-muted">
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Tags
|
||||
</button>
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Tags</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
|
||||
@ -99,12 +97,20 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
<div class="list-group mt-1 mb-2">
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav"
|
||||
@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)"
|
||||
<RouterLink
|
||||
v-for="tag in mailbox.tags"
|
||||
:key="tag"
|
||||
:to="toTagUrl(tag)"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||
<i class="bi bi-tag" v-else></i>
|
||||
class="list-group-item list-group-item-action small px-2"
|
||||
:class="inSearch(tag) ? 'active' : ''"
|
||||
@click.exact="hideNav"
|
||||
@click="pagination.start = 0"
|
||||
@click.meta="toggleTag($event, tag)"
|
||||
@click.ctrl="toggleTag($event, tag)"
|
||||
>
|
||||
<i v-if="inSearch(tag)" class="bi bi-tag-fill"></i>
|
||||
<i v-else class="bi bi-tag"></i>
|
||||
{{ tag }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
@ -1,263 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { Toast } from 'bootstrap'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
clientErrors: [], // errors received via websocket
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const d = document.getElementById('app')
|
||||
if (d) {
|
||||
this.version = d.dataset.version
|
||||
}
|
||||
|
||||
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
|
||||
|
||||
this.socketBreakReset()
|
||||
this.connect()
|
||||
|
||||
mailbox.notificationsSupported = window.isSecureContext
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
|
||||
this.errorNotificationCron()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect() {
|
||||
const ws = new WebSocket(this.socketURI)
|
||||
ws.onmessage = (e) => {
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(e.data)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
this.eventBus.emit("new", response.Data)
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
|
||||
mailbox.tags.push(response.Data.Tags[i])
|
||||
mailbox.tags.sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!this.pauseNotifications) {
|
||||
this.pauseNotifications = true
|
||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
|
||||
this.browserNotify("New mail from: " + from, response.Data.Subject)
|
||||
this.setMessageToast(response.Data)
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
|
||||
}
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
mailbox.unread = response.Data.Unread
|
||||
|
||||
// detect version updated, refresh is needed
|
||||
if (this.version != response.Data.Version) {
|
||||
location.reload()
|
||||
}
|
||||
} else if (response.Type == "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data)
|
||||
} else if (response.Type == "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data)
|
||||
} else if (response.Type == "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate")
|
||||
} else if (response.Type == "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
mailbox.connected = true
|
||||
this.socketLastConnection = Date.now()
|
||||
if (this.reconnectRefresh) {
|
||||
this.reconnectRefresh = false
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (this.socketLastConnection == 0) {
|
||||
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
||||
console.log('Unable to connect to websocket, disabling websocket support')
|
||||
return
|
||||
}
|
||||
|
||||
if (mailbox.connected) {
|
||||
// count disconnections
|
||||
this.socketBreaks++
|
||||
}
|
||||
|
||||
// set disconnected state
|
||||
mailbox.connected = false
|
||||
|
||||
if (this.socketBreaks > 3) {
|
||||
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
||||
// something is not working right on their end, see issue #319
|
||||
console.log('Unstable websocket connection, disabling websocket support')
|
||||
return
|
||||
}
|
||||
if (Date.now() - this.socketLastConnection > 5000) {
|
||||
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
||||
this.reconnectRefresh = true
|
||||
} else {
|
||||
this.reconnectRefresh = false
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect() // reconnect
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
ws.onerror = function (err) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0
|
||||
this.socketBreakReset()
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
browserNotify(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let options = {
|
||||
body: message,
|
||||
icon: this.resolve('/notification.png')
|
||||
}
|
||||
new Notification(title, options)
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast(m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
this.toastMessage = m
|
||||
|
||||
const el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
el.addEventListener('hidden.bs.toast', () => {
|
||||
this.toastMessage = false
|
||||
})
|
||||
|
||||
Toast.getOrCreateInstance(el).show()
|
||||
}
|
||||
},
|
||||
|
||||
closeToast() {
|
||||
const el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide()
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000 // expire after 5s
|
||||
this.clientErrors.push(d)
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1)
|
||||
}
|
||||
})
|
||||
this.errorNotificationCron()
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
|
||||
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" v-if="toastMessage">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast">
|
||||
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else>
|
||||
[ no subject ]
|
||||
</template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,107 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { limitOptions, pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: Number,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
limitOptions,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev() {
|
||||
return pagination.start > 0
|
||||
},
|
||||
|
||||
canNext() {
|
||||
return this.total > (pagination.start + mailbox.messages.length)
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages() {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10)
|
||||
if (t > this.total) {
|
||||
t = this.total
|
||||
}
|
||||
|
||||
return t
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit() {
|
||||
pagination.start = 0
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
viewNext() {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
viewPrev() {
|
||||
let s = pagination.start - pagination.limit
|
||||
if (s < 0) {
|
||||
s = 0
|
||||
}
|
||||
pagination.start = s
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
updateQueryParams() {
|
||||
const path = this.$route.path
|
||||
const p = {
|
||||
...this.$route.query
|
||||
}
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
} else {
|
||||
delete p.start
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
} else {
|
||||
delete p.limit
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push(path + '?' + params.toString())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0">
|
||||
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
@ -1,78 +1,84 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.searchFromURL()
|
||||
search: "",
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route() {
|
||||
this.searchFromURL()
|
||||
}
|
||||
this.searchFromURL();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.searchFromURL();
|
||||
},
|
||||
|
||||
methods: {
|
||||
searchFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
this.search = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.search = urlParams.get("q") ? urlParams.get("q") : "";
|
||||
},
|
||||
|
||||
doSearch(e) {
|
||||
pagination.start = 0
|
||||
if (this.search == '') {
|
||||
this.$router.push('/')
|
||||
pagination.start = 0;
|
||||
if (this.search === "") {
|
||||
this.$router.push("/");
|
||||
} else {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const curr = urlParams.get('q')
|
||||
if (curr && curr == this.search) {
|
||||
pagination.start = 0
|
||||
this.$emit('loadMessages')
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const curr = urlParams.get("q");
|
||||
if (curr && curr === this.search) {
|
||||
pagination.start = 0;
|
||||
this.$emit("loadMessages");
|
||||
}
|
||||
const p = {
|
||||
q: this.search
|
||||
}
|
||||
q: this.search,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
p.start = pagination.start.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push('/search?' + params.toString())
|
||||
const params = new URLSearchParams(p);
|
||||
this.$router.push("/search?" + params.toString());
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
resetSearch() {
|
||||
this.search = ''
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
this.search = "";
|
||||
this.$router.push("/");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-on:submit="doSearch">
|
||||
<form @submit="doSearch">
|
||||
<div class="input-group flex-nowrap">
|
||||
<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
|
||||
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
|
||||
placeholder="Search mailbox">
|
||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''"
|
||||
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||
<input
|
||||
v-model.trim="search"
|
||||
type="text"
|
||||
class="form-control border-0"
|
||||
aria-label="Search"
|
||||
placeholder="Search mailbox"
|
||||
/>
|
||||
<span v-if="search != ''" class="btn btn-link position-absolute end-0 text-muted" @click="resetSearch"
|
||||
><i class="bi bi-x-circle"></i
|
||||
></span>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" type="submit">
|
||||
<i class="bi bi-search"></i>
|
||||
|
@ -1,295 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import Tags from 'bootstrap5-tags'
|
||||
import timezones from 'timezones-list'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(v) {
|
||||
if (v == 'auto') {
|
||||
localStorage.removeItem('theme')
|
||||
} else {
|
||||
localStorage.setItem('theme', v)
|
||||
}
|
||||
this.setTheme()
|
||||
},
|
||||
|
||||
chaosConfig: {
|
||||
handler() {
|
||||
this.chaosUpdated = true
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
||||
'mailbox.skipConfirmations'(v) {
|
||||
if (v) {
|
||||
localStorage.setItem('skip-confirmations', 'true')
|
||||
} else {
|
||||
localStorage.removeItem('skip-confirmations')
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme()
|
||||
this.$nextTick(function () {
|
||||
Tags.init('select.tz')
|
||||
})
|
||||
|
||||
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
|
||||
},
|
||||
|
||||
methods: {
|
||||
setTheme() {
|
||||
if (
|
||||
this.theme === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', this.theme)
|
||||
}
|
||||
},
|
||||
|
||||
loadChaos() {
|
||||
this.get(this.resolve('/api/v1/chaos'), null, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
saveChaos() {
|
||||
this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="ui-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane"
|
||||
aria-selected="true">Web UI</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chaos-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane"
|
||||
aria-selected="false" @click="loadChaos">Chaos</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab"
|
||||
tabindex="0">
|
||||
<div class="my-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select class="form-select" v-model="theme" id="theme">
|
||||
<option value="auto">Auto (detect from browser)</option>
|
||||
<option value="light">Light theme</option>
|
||||
<option value="dark">Dark theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone"
|
||||
data-allow-same="true">
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
|
||||
v-model="mailbox.showTagColors">
|
||||
<label class="form-check-label" for="tagColors">
|
||||
Use auto-generated tag colors
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck">
|
||||
<label class="form-check-label" for="htmlCheck">
|
||||
Show HTML check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck">
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck">
|
||||
<label class="form-check-label" for="spamCheck">
|
||||
Show spam check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="skip-confirmations" v-model="mailbox.skipConfirmations">
|
||||
<label class="form-check-label" for="skip-confirmations">
|
||||
Skip
|
||||
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
||||
<code>Delete all</code> &
|
||||
</template>
|
||||
<code>Mark all read</code> confirmation dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab"
|
||||
tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<p class="my-3">
|
||||
<b>Chaos</b> allows you to set random SMTP failures and response codes at various
|
||||
stages in a SMTP transaction to test application resilience
|
||||
(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank">
|
||||
see documentation
|
||||
</a>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>Response code</code> is the SMTP error code returned by the server if this
|
||||
error is triggered. Error codes must range between 400 and 599.
|
||||
</li>
|
||||
<li>
|
||||
<code>Error probability</code> is the % chance that the error will occur per message
|
||||
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
||||
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
||||
messages received.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="chaosConfig">
|
||||
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Sender</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the sender (From / Sender).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Sender.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Recipient</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the recipients (To, Cc, Bcc).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Recipient.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Authentication</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an authentication error response.
|
||||
Note that SMTP authentication must be configured too.
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Authentication.ErrorCode" min="400"
|
||||
max="599" required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Authentication.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chaosUpdated" class="mb-3 text-center">
|
||||
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,221 +1,234 @@
|
||||
<script>
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
import { VcDonut } from "vue-css-donut-chart";
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
check: false,
|
||||
platforms: [],
|
||||
allPlatforms: {
|
||||
"windows": "Windows",
|
||||
windows: "Windows",
|
||||
"windows-mail": "Windows Mail",
|
||||
"outlook-com": "Outlook.com",
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
"android": "Android",
|
||||
macos: "macOS",
|
||||
ios: "iOS",
|
||||
android: "Android",
|
||||
"desktop-webmail": "Desktop Webmail",
|
||||
"mobile-webmail": "Mobile Webmail",
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConfig()
|
||||
this.doCheck()
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
summary() {
|
||||
if (!this.check) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = {
|
||||
const result = {
|
||||
Warnings: [],
|
||||
Total: {
|
||||
Nodes: this.check.Total.Nodes
|
||||
}
|
||||
}
|
||||
Nodes: this.check.Total.Nodes,
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.check.Warnings.length; i++) {
|
||||
let o = JSON.parse(JSON.stringify(this.check.Warnings[i]))
|
||||
const o = JSON.parse(JSON.stringify(this.check.Warnings[i]));
|
||||
|
||||
// for <script> test
|
||||
if (o.Results.length == 0) {
|
||||
result.Warnings.push(o)
|
||||
continue
|
||||
if (o.Results.length === 0) {
|
||||
result.Warnings.push(o);
|
||||
continue;
|
||||
}
|
||||
|
||||
// filter by enabled platforms
|
||||
let results = o.Results.filter((w) => {
|
||||
return this.platforms.indexOf(w.Platform) != -1
|
||||
})
|
||||
const results = o.Results.filter((w) => {
|
||||
return this.platforms.indexOf(w.Platform) !== -1;
|
||||
});
|
||||
|
||||
if (results.length == 0) {
|
||||
continue
|
||||
if (results.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// recalculate the percentages
|
||||
let y = 0, p = 0, n = 0
|
||||
let y = 0;
|
||||
let p = 0;
|
||||
let n = 0;
|
||||
|
||||
results.forEach(function (r) {
|
||||
if (r.Support == "yes") {
|
||||
y++
|
||||
} else if (r.Support == "partial") {
|
||||
p++
|
||||
results.forEach((r) => {
|
||||
if (r.Support === "yes") {
|
||||
y++;
|
||||
} else if (r.Support === "partial") {
|
||||
p++;
|
||||
} else {
|
||||
n++
|
||||
n++;
|
||||
}
|
||||
})
|
||||
let total = y + p + n
|
||||
o.Results = results
|
||||
});
|
||||
const total = y + p + n;
|
||||
o.Results = results;
|
||||
o.Score = {
|
||||
Found: o.Score.Found,
|
||||
Supported: y / total * 100,
|
||||
Partial: p / total * 100,
|
||||
Unsupported: n / total * 100
|
||||
}
|
||||
Supported: (y / total) * 100,
|
||||
Partial: (p / total) * 100,
|
||||
Unsupported: (n / total) * 100,
|
||||
};
|
||||
|
||||
result.Warnings.push(o)
|
||||
result.Warnings.push(o);
|
||||
}
|
||||
|
||||
let maxPartial = 0, maxUnsupported = 0
|
||||
let maxPartial = 0;
|
||||
let maxUnsupported = 0;
|
||||
result.Warnings.forEach((w) => {
|
||||
let scoreWeight = 1
|
||||
let scoreWeight = 1;
|
||||
if (w.Score.Found < result.Total.Nodes) {
|
||||
// each error is weighted based on the number of occurrences vs: the total message nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes;
|
||||
}
|
||||
|
||||
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
|
||||
// are actually used in the HTML, and including things like bootstrap styles completely throws
|
||||
// off the calculation as these dominate.
|
||||
if (this.isPseudoClassOrAtRule(w.Title)) {
|
||||
scoreWeight = 0.05
|
||||
w.PseudoClassOrAtRule = true
|
||||
scoreWeight = 0.05;
|
||||
w.PseudoClassOrAtRule = true;
|
||||
}
|
||||
|
||||
let scorePartial = w.Score.Partial * scoreWeight
|
||||
let scoreUnsupported = w.Score.Unsupported * scoreWeight
|
||||
const scorePartial = w.Score.Partial * scoreWeight;
|
||||
const scoreUnsupported = w.Score.Unsupported * scoreWeight;
|
||||
if (scorePartial > maxPartial) {
|
||||
maxPartial = scorePartial
|
||||
maxPartial = scorePartial;
|
||||
}
|
||||
if (scoreUnsupported > maxUnsupported) {
|
||||
maxUnsupported = scoreUnsupported
|
||||
maxUnsupported = scoreUnsupported;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// sort warnings by final score
|
||||
result.Warnings.sort((a, b) => {
|
||||
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
|
||||
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
|
||||
let aWeight =
|
||||
a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes;
|
||||
let bWeight =
|
||||
b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes;
|
||||
|
||||
if (this.isPseudoClassOrAtRule(a.Title)) {
|
||||
aWeight = 0.05
|
||||
aWeight = 0.05;
|
||||
}
|
||||
|
||||
if (this.isPseudoClassOrAtRule(b.Title)) {
|
||||
bWeight = 0.05
|
||||
bWeight = 0.05;
|
||||
}
|
||||
|
||||
return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
})
|
||||
return (
|
||||
(a.Score.Unsupported + a.Score.Partial) * aWeight <
|
||||
(b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
);
|
||||
});
|
||||
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported
|
||||
result.Total.Partial = maxPartial
|
||||
result.Total.Unsupported = maxUnsupported
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported;
|
||||
result.Total.Partial = maxPartial;
|
||||
result.Total.Unsupported = maxUnsupported;
|
||||
|
||||
this.$emit('setHtmlScore', result.Total.Supported)
|
||||
this.$emit("setHtmlScore", result.Total.Supported);
|
||||
|
||||
return result
|
||||
return result;
|
||||
},
|
||||
|
||||
graphSections() {
|
||||
let s = Math.round(this.summary.Total.Supported)
|
||||
let p = Math.round(this.summary.Total.Partial)
|
||||
let u = 100 - s - p
|
||||
const s = Math.round(this.summary.Total.Supported);
|
||||
const p = Math.round(this.summary.Total.Partial);
|
||||
const u = 100 - s - p;
|
||||
return [
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Supported) + '% supported',
|
||||
label: this.round2dm(this.summary.Total.Supported) + "% supported",
|
||||
value: s,
|
||||
color: '#198754'
|
||||
color: "#198754",
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Partial) + '% partially supported',
|
||||
label: this.round2dm(this.summary.Total.Partial) + "% partially supported",
|
||||
value: p,
|
||||
color: '#ffc107'
|
||||
color: "#ffc107",
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + '% not supported',
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + "% not supported",
|
||||
value: u,
|
||||
color: '#dc3545'
|
||||
}
|
||||
]
|
||||
color: "#dc3545",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// colors depend on both varying unsupported & partially unsupported percentages
|
||||
scoreColor() {
|
||||
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
|
||||
this.$emit('setBadgeStyle', 'bg-success')
|
||||
return 'text-success'
|
||||
this.$emit("setBadgeStyle", "bg-success");
|
||||
return "text-success";
|
||||
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
|
||||
this.$emit('setBadgeStyle', 'bg-warning text-primary')
|
||||
return 'text-warning'
|
||||
this.$emit("setBadgeStyle", "bg-warning text-primary");
|
||||
return "text-warning";
|
||||
}
|
||||
|
||||
this.$emit('setBadgeStyle', 'bg-danger')
|
||||
return 'text-danger'
|
||||
}
|
||||
this.$emit("setBadgeStyle", "bg-danger");
|
||||
return "text-danger";
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setHtmlScore', false)
|
||||
this.doCheck()
|
||||
this.$emit("setHtmlScore", false);
|
||||
this.doCheck();
|
||||
},
|
||||
deep: true
|
||||
deep: true,
|
||||
},
|
||||
platforms(v) {
|
||||
localStorage.setItem('html-check-platforms', JSON.stringify(v))
|
||||
localStorage.setItem("html-check-platforms", JSON.stringify(v));
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConfig();
|
||||
this.doCheck();
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.check = false;
|
||||
|
||||
if (this.message.HTML == "") {
|
||||
return
|
||||
if (this.message.HTML === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null)
|
||||
axios
|
||||
.get(this.resolve("/api/v1/message/" + this.message.ID + "/html-check"), null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
|
||||
// set tooltips
|
||||
window.setTimeout(() => {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
}, 500)
|
||||
[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
|
||||
}, 500);
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@ -223,68 +236,72 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
loadConfig() {
|
||||
let platforms = localStorage.getItem('html-check-platforms')
|
||||
const platforms = localStorage.getItem("html-check-platforms");
|
||||
if (platforms) {
|
||||
try {
|
||||
this.platforms = JSON.parse(platforms)
|
||||
} catch (e) {
|
||||
}
|
||||
this.platforms = JSON.parse(platforms);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// set all options
|
||||
if (this.platforms.length == 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms)
|
||||
if (this.platforms.length === 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms);
|
||||
}
|
||||
},
|
||||
|
||||
// return a platform's families (email clients)
|
||||
families(k) {
|
||||
if (this.check.Platforms[k]) {
|
||||
return this.check.Platforms[k]
|
||||
return this.check.Platforms[k];
|
||||
}
|
||||
|
||||
return []
|
||||
return [];
|
||||
},
|
||||
|
||||
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
|
||||
isPseudoClassOrAtRule(t) {
|
||||
return t.match(/^(:|@)/)
|
||||
return t.match(/^(:|@)/);
|
||||
},
|
||||
|
||||
round(v) {
|
||||
return Math.round(v)
|
||||
return Math.round(v);
|
||||
},
|
||||
|
||||
round2dm(v) {
|
||||
return Math.round(v * 100) / 100
|
||||
return Math.round(v * 100) / 100;
|
||||
},
|
||||
|
||||
scrollToWarnings() {
|
||||
if (!this.$refs.warnings) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" })
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
sanitizeHTML(html) {
|
||||
return DOMPurify.sanitize(html);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -299,39 +316,50 @@ export default {
|
||||
<div class="mt-5 mb-3">
|
||||
<div class="row w-100">
|
||||
<div class="col-md-8">
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
|
||||
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
|
||||
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
|
||||
<vc-donut
|
||||
:sections="graphSections"
|
||||
background="var(--bs-body-bg)"
|
||||
:size="180"
|
||||
unit="px"
|
||||
:thickness="20"
|
||||
has-legend
|
||||
legend-placement="bottom"
|
||||
:total="100"
|
||||
:start-angle="0"
|
||||
:auto-adjust-text-size="true"
|
||||
@section-click="scrollToWarnings"
|
||||
>
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ round2dm(summary.Total.Supported) }}%
|
||||
</h2>
|
||||
<div class="text-body">
|
||||
support
|
||||
</div>
|
||||
<div class="text-body">support</div>
|
||||
<template #legend>
|
||||
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-success"></i>
|
||||
{{ round2dm(summary.Total.Supported) }}% supported
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-warning"></i>
|
||||
{{ round2dm(summary.Total.Partial) }}% partially supported
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-danger"></i>
|
||||
{{ round2dm(summary.Total.Unsupported) }}% not supported
|
||||
</span>
|
||||
</p>
|
||||
<p class="small text-muted">
|
||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
||||
</p>
|
||||
<p class="small text-muted">calculated from {{ formatNumber(check.Total.Tests) }} tests</p>
|
||||
</template>
|
||||
</vc-donut>
|
||||
|
||||
<div class="input-group justify-content-center mb-3">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults"
|
||||
>
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
@ -339,12 +367,24 @@ export default {
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<h2 class="h5 mb-3">Tested platforms:</h2>
|
||||
<div class="form-check form-switch" v-for="p, k in allPlatforms">
|
||||
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms"
|
||||
:aria-label="p" :id="'Check_' + k">
|
||||
<label class="form-check-label" :for="'Check_' + k"
|
||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'" :title="families(k).join(', ')"
|
||||
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
|
||||
<div v-for="(p, k) in allPlatforms" :key="'check_' + k" class="form-check form-switch">
|
||||
<input
|
||||
:id="'Check_' + k"
|
||||
v-model="platforms"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
:value="k"
|
||||
:aria-label="p"
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
:for="'Check_' + k"
|
||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'"
|
||||
:title="families(k).join(', ')"
|
||||
data-bs-toggle="tooltip"
|
||||
:data-bs-title="families(k).join(', ')"
|
||||
>
|
||||
{{ p }}
|
||||
</label>
|
||||
</div>
|
||||
@ -356,45 +396,72 @@ export default {
|
||||
<h4 ref="warnings" class="h5 mt-4">
|
||||
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
|
||||
</h4>
|
||||
<div class="accordion" id="warnings">
|
||||
<div class="accordion-item" v-for="warning in summary.Warnings">
|
||||
<div id="warnings" class="accordion">
|
||||
<div v-for="(warning, i) in summary.Warnings" :key="'warning_' + i" class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug"
|
||||
aria-expanded="false"
|
||||
:aria-controls="warning.Slug"
|
||||
>
|
||||
<div class="row w-100 w-lg-75">
|
||||
<div class="col-sm">
|
||||
{{ warning.Title }}
|
||||
<span class="ms-2 small badge text-bg-secondary" title="Test category">
|
||||
{{ warning.Category }}
|
||||
</span>
|
||||
<span class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected">
|
||||
<span
|
||||
class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected"
|
||||
>
|
||||
x {{ warning.Score.Found }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm mt-2 mt-sm-0">
|
||||
<div class="progress-stacked">
|
||||
<div class="progress" role="progressbar" aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported" aria-valuemin="0"
|
||||
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }"
|
||||
title="Supported">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Supported + '%' }"
|
||||
title="Supported"
|
||||
>
|
||||
<div class="progress-bar bg-success">
|
||||
{{ round(warning.Score.Supported) + '%' }}
|
||||
{{ round(warning.Score.Supported) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }" title="Partial support">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }"
|
||||
title="Partial support"
|
||||
>
|
||||
<div class="progress-bar progress-bar-striped bg-warning text-dark">
|
||||
{{ round(warning.Score.Partial) + '%' }}
|
||||
{{ round(warning.Score.Partial) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0"
|
||||
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }"
|
||||
title="Not supported">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Unsupported + '%' }"
|
||||
title="Not supported"
|
||||
>
|
||||
<div class="progress-bar bg-danger">
|
||||
{{ round(warning.Score.Unsupported) + '%' }}
|
||||
{{ round(warning.Score.Unsupported) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -404,28 +471,45 @@ export default {
|
||||
</h2>
|
||||
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
|
||||
<div class="accordion-body">
|
||||
<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule">
|
||||
<p v-if="warning.Description !== '' || warning.PseudoClassOrAtRule">
|
||||
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
|
||||
propert<template v-if="warning.Score.Found === 1">y</template><template
|
||||
v-else>ies</template> in the CSS
|
||||
styles, but unable to test if used or not.
|
||||
<template v-if="warning.Score.Found === 1">property</template>
|
||||
<template v-else>properties</template>
|
||||
in the CSS styles, but unable to test if used or not.
|
||||
</span>
|
||||
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<span
|
||||
v-if="warning.Description !== ''"
|
||||
class="me-2"
|
||||
v-html="sanitizeHTML(warning.Description)"
|
||||
></span>
|
||||
<!-- -eslint-disable vue/no-v-html -->
|
||||
</p>
|
||||
|
||||
<template v-if="warning.Results.length">
|
||||
<h3 class="h6">Clients with partial or no support:</h3>
|
||||
<p>
|
||||
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4">
|
||||
<i class="bi bi-circle-fill"
|
||||
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i>
|
||||
{{ warning.Name }}
|
||||
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''"
|
||||
title="See notes">
|
||||
{{ warning.NoteNumber }}
|
||||
<small
|
||||
v-for="(warningRes, wi) in warning.Results"
|
||||
:key="'warning_results_' + wi"
|
||||
class="text-nowrap d-inline-block me-4"
|
||||
>
|
||||
<i
|
||||
class="bi bi-circle-fill"
|
||||
:class="warningRes.Support === 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="
|
||||
warningRes.Support === 'no' ? 'Not supported' : 'Partially supported'
|
||||
"
|
||||
></i>
|
||||
{{ warningRes.Name }}
|
||||
<span
|
||||
v-if="warningRes.NoteNumber !== ''"
|
||||
class="badge text-bg-secondary"
|
||||
title="See notes"
|
||||
>
|
||||
{{ warningRes.NoteNumber }}
|
||||
</span>
|
||||
</small>
|
||||
</p>
|
||||
@ -433,17 +517,21 @@ export default {
|
||||
|
||||
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
|
||||
<h3 class="h6">Notes:</h3>
|
||||
<div v-for="n, i in warning.NotesByNumber" class="small row my-2">
|
||||
<div
|
||||
v-for="(n, ni) in warning.NotesByNumber"
|
||||
:key="'warning_notes' + ni"
|
||||
class="small row my-2"
|
||||
>
|
||||
<div class="col-auto pe-0">
|
||||
<span class="badge text-bg-secondary">
|
||||
{{ i }}
|
||||
{{ ni }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col" v-html="n"></div>
|
||||
<div class="col" v-html="sanitizeHTML(n)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small mt-3 mb-0" v-if="warning.URL">
|
||||
<p v-if="warning.URL" class="small mt-3 mb-0">
|
||||
<a :href="warning.URL" target="_blank">Online reference</a>
|
||||
</p>
|
||||
</div>
|
||||
@ -452,30 +540,44 @@ export default {
|
||||
</div>
|
||||
|
||||
<p class="text-center text-muted small mt-4">
|
||||
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using
|
||||
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
||||
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using compatibility data
|
||||
from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutHTMLCheckResults"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1>
|
||||
<h1 id="AboutHTMLCheckResultsLabel" class="modal-title fs-5">About HTML check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="HTMLCheckAboutAccordion">
|
||||
<div id="HTMLCheckAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is HTML check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
The support for HTML/CSS messages varies greatly across email clients. HTML
|
||||
check attempts to calculate the overall support for your email for all selected
|
||||
@ -485,13 +587,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
How does it work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Internally the original HTML message is run against
|
||||
@ -504,10 +615,11 @@ export default {
|
||||
CSS support is very difficult to programmatically test, especially if a
|
||||
message contains CSS style blocks or is linked to remote stylesheets. Remote
|
||||
stylesheets are, unless blocked via
|
||||
<code>--block-remote-css-and-fonts</code>,
|
||||
downloaded and injected into the message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer"
|
||||
target="_blank">inlined</a>
|
||||
<code>--block-remote-css-and-fonts</code>, downloaded and injected into the
|
||||
message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer" target="_blank"
|
||||
>inlined</a
|
||||
>
|
||||
to matching HTML elements. This gives Mailpit fairly accurate results.
|
||||
</p>
|
||||
<p>
|
||||
@ -528,13 +640,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
Is the final score accurate?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
There are many ways to define "accurate", and how one should calculate the
|
||||
@ -578,13 +699,22 @@ export default {
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
What about invalid HTML?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
HTML check does not detect if the original HTML is valid. In order to detect
|
||||
applied styles to every node, the HTML email is run through a parser which is
|
||||
@ -592,7 +722,6 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -1,36 +0,0 @@
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
headers: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
|
||||
this.get(uri, false, (response) => {
|
||||
this.headers = response.data
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="headers" class="small">
|
||||
<div v-for="values, k in headers" class="row mb-2 pb-2 border-bottom w-100">
|
||||
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,16 +1,19 @@
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: Object,
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setLinkErrors"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
@ -19,116 +22,116 @@ export default {
|
||||
check: false,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.autoScan = localStorage.getItem('LinkCheckAutoScan')
|
||||
this.followRedirects = localStorage.getItem('LinkCheckFollowRedirects')
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loaded = true
|
||||
if (this.autoScan) {
|
||||
this.doCheck()
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
autoScan(v) {
|
||||
if (!this.loaded) {
|
||||
return
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem('LinkCheckAutoScan', true)
|
||||
if (!this.check) {
|
||||
this.doCheck()
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('LinkCheckAutoScan')
|
||||
}
|
||||
},
|
||||
followRedirects(v) {
|
||||
if (!this.loaded) {
|
||||
return
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem('LinkCheckFollowRedirects', true)
|
||||
} else {
|
||||
localStorage.removeItem('LinkCheckFollowRedirects')
|
||||
}
|
||||
if (this.check) {
|
||||
this.doCheck()
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
groupedStatuses() {
|
||||
let results = {}
|
||||
const results = {};
|
||||
|
||||
if (!this.check) {
|
||||
return results
|
||||
return results;
|
||||
}
|
||||
|
||||
// group by status
|
||||
this.check.Links.forEach(function (r) {
|
||||
this.check.Links.forEach((r) => {
|
||||
if (!results[r.StatusCode]) {
|
||||
let css = ""
|
||||
let css = "";
|
||||
if (r.StatusCode >= 400 || r.StatusCode === 0) {
|
||||
css = "text-danger"
|
||||
css = "text-danger";
|
||||
} else if (r.StatusCode >= 300) {
|
||||
css = "text-info"
|
||||
css = "text-info";
|
||||
}
|
||||
|
||||
if (r.StatusCode === 0) {
|
||||
r.Status = 'Cannot connect to server'
|
||||
r.Status = "Cannot connect to server";
|
||||
}
|
||||
results[r.StatusCode] = {
|
||||
StatusCode: r.StatusCode,
|
||||
Status: r.Status,
|
||||
Class: css,
|
||||
URLS: []
|
||||
}
|
||||
URLS: [],
|
||||
};
|
||||
}
|
||||
results[r.StatusCode].URLS.push(r.URL)
|
||||
})
|
||||
results[r.StatusCode].URLS.push(r.URL);
|
||||
});
|
||||
|
||||
let newArr = []
|
||||
const newArr = [];
|
||||
|
||||
for (const i in results) {
|
||||
newArr.push(results[i])
|
||||
newArr.push(results[i]);
|
||||
}
|
||||
|
||||
// sort statuses
|
||||
let sorted = newArr.sort((a, b) => {
|
||||
const sorted = newArr.sort((a, b) => {
|
||||
if (a.StatusCode === 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
return a.StatusCode < b.StatusCode
|
||||
})
|
||||
return a.StatusCode < b.StatusCode;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
},
|
||||
},
|
||||
|
||||
return sorted
|
||||
watch: {
|
||||
autoScan(v) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem("LinkCheckAutoScan", true);
|
||||
if (!this.check) {
|
||||
this.doCheck();
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem("LinkCheckAutoScan");
|
||||
}
|
||||
},
|
||||
followRedirects(v) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem("LinkCheckFollowRedirects", true);
|
||||
} else {
|
||||
localStorage.removeItem("LinkCheckFollowRedirects");
|
||||
}
|
||||
if (this.check) {
|
||||
this.doCheck();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.autoScan = localStorage.getItem("LinkCheckAutoScan");
|
||||
this.followRedirects = localStorage.getItem("LinkCheckFollowRedirects");
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loaded = true;
|
||||
if (this.autoScan) {
|
||||
this.doCheck();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.loading = true
|
||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
|
||||
this.check = false;
|
||||
this.loading = true;
|
||||
let uri = this.resolve("/api/v1/message/" + this.message.ID + "/link-check");
|
||||
if (this.followRedirects) {
|
||||
uri += '?follow=true'
|
||||
uri += "?follow=true";
|
||||
}
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(uri, null)
|
||||
axios
|
||||
.get(uri, null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
|
||||
this.$emit('setLinkErrors', result.data.Errors)
|
||||
this.$emit("setLinkErrors", result.data.Errors);
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@ -136,27 +139,27 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
// always run
|
||||
this.loading = false
|
||||
})
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -164,24 +167,24 @@ export default {
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h4 class="mb-0">
|
||||
<template v-if="!check">
|
||||
Link check
|
||||
</template>
|
||||
<template v-if="!check"> Link check </template>
|
||||
<template v-else>
|
||||
<template v-if="check.Links.length">
|
||||
Scanned {{ formatNumber(check.Links.length) }}
|
||||
link<template v-if="check.Links.length != 1">s</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
No links detected
|
||||
Scanned {{ formatNumber(check.Links.length) }} link<template v-if="check.Links.length != 1"
|
||||
>s</template
|
||||
>
|
||||
</template>
|
||||
<template v-else> No links detected </template>
|
||||
</template>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutLinkCheckResults">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#AboutLinkCheckResults"
|
||||
>
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
@ -195,12 +198,12 @@ export default {
|
||||
|
||||
<div v-if="!check">
|
||||
<p class="text-muted">
|
||||
Link check scans your email text & HTML for unique links, testing the response status codes.
|
||||
This includes links to images and remote CSS stylesheets.
|
||||
Link check scans your email text & HTML for unique links, testing the response status codes. This
|
||||
includes links to images and remote CSS stylesheets.
|
||||
</p>
|
||||
|
||||
<p class="text-center my-5">
|
||||
<button v-if="!check" class="btn btn-primary btn-lg" @click="doCheck()" :disabled="loading">
|
||||
<button v-if="!check" class="btn btn-primary btn-lg" :disabled="loading" @click="doCheck()">
|
||||
<template v-if="loading">
|
||||
Checking links
|
||||
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
|
||||
@ -215,14 +218,14 @@ export default {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else v-for="s, k in groupedStatuses">
|
||||
<div v-for="(s, k) in groupedStatuses" v-else :key="k">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header h4" :class="s.Class">
|
||||
Status {{ s.StatusCode }}
|
||||
<small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li v-for="u in s.URLS" class="list-group-item">
|
||||
<li v-for="(u, i) in s.URLS" :key="'status' + i" class="list-group-item">
|
||||
<a :href="u" target="_blank" class="no-icon">{{ u }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
@ -235,22 +238,31 @@ export default {
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="LinkCheckOptions"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="LinkCheckOptionsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="LinkCheckOptionsLabel">Link check options</h1>
|
||||
<h1 id="LinkCheckOptionsLabel" class="modal-title fs-5">Link check options</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
|
||||
<div class="form-check form-switch mb-4">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects"
|
||||
id="LinkCheckFollowRedirectsSwitch">
|
||||
<input
|
||||
id="LinkCheckFollowRedirectsSwitch"
|
||||
v-model="followRedirects"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
|
||||
<template v-if="followRedirects">Following HTTP redirects</template>
|
||||
<template v-else>Not following HTTP redirects</template>
|
||||
@ -259,8 +271,13 @@ export default {
|
||||
|
||||
<h6 class="mt-4">Automatic link checking</h6>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="autoScan"
|
||||
id="LinkCheckAutoCheckSwitch">
|
||||
<input
|
||||
id="LinkCheckAutoCheckSwitch"
|
||||
v-model="autoScan"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="LinkCheckAutoCheckSwitch">
|
||||
<template v-if="autoScan">Automatic link checking is enabled</template>
|
||||
<template v-else>Automatic link checking is disabled</template>
|
||||
@ -270,7 +287,6 @@ export default {
|
||||
Only enable this if you understand the potential risks & consequences.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@ -279,25 +295,39 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="AboutLinkCheckResults" tabindex="-1" aria-labelledby="AboutLinkCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutLinkCheckResults"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutLinkCheckResultsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutLinkCheckResultsLabel">About Link check</h1>
|
||||
<h1 id="AboutLinkCheckResultsLabel" class="modal-title fs-5">About Link check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="LinkCheckAboutAccordion">
|
||||
<div id="LinkCheckAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is Link check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
Link check scans your message HTML and text for all unique links, images and linked
|
||||
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
|
||||
@ -307,35 +337,52 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
What are "301" and "302" links?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
These are links that redirect you to another URL, for example newsletters
|
||||
often use redirect links to track user clicks.
|
||||
These are links that redirect you to another URL, for example newsletters often
|
||||
use redirect links to track user clicks.
|
||||
</p>
|
||||
<p>
|
||||
By default Link check will not follow these links, however you can turn this on
|
||||
via
|
||||
the settings and Link check will "follow" those redirects.
|
||||
via the settings and Link check will "follow" those redirects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
Why are some links returning an error but work in my browser?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>This may be due to various reasons, for instance:</p>
|
||||
<ul>
|
||||
@ -345,20 +392,29 @@ export default {
|
||||
The webserver is blocking requests that don't come from authenticated web
|
||||
browsers.
|
||||
</li>
|
||||
<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests. </li>
|
||||
<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
What are the risks of running Link check automatically?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Depending on the type of messages you are testing, opening all links on all
|
||||
@ -382,7 +438,6 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -1,657 +0,0 @@
|
||||
<script>
|
||||
import Attachments from './Attachments.vue'
|
||||
import Headers from './Headers.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 {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
Attachments,
|
||||
Headers,
|
||||
HTMLCheck,
|
||||
LinkCheck,
|
||||
SpamAssassin,
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
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%',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
messageTags() {
|
||||
if (this.canSaveTags) {
|
||||
// save changes to tags
|
||||
this.saveTags()
|
||||
}
|
||||
},
|
||||
|
||||
scaleHTMLPreview(v) {
|
||||
if (v == 'display') {
|
||||
window.setTimeout(() => {
|
||||
this.resizeIFrames()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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',
|
||||
],
|
||||
FORBID_ATTR: ['script'],
|
||||
}
|
||||
)
|
||||
|
||||
// for debugging
|
||||
// this.debugDOMPurify(DOMPurify.removed)
|
||||
|
||||
return clean
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.canSaveTags = false
|
||||
this.messageTags = this.message.Tags
|
||||
this.renderUI()
|
||||
|
||||
window.addEventListener("resize", this.resizeIFrames)
|
||||
|
||||
let headersTab = document.getElementById('nav-headers-tab')
|
||||
headersTab.addEventListener('shown.bs.tab', (event) => {
|
||||
this.loadHeaders = true
|
||||
})
|
||||
|
||||
let 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(() => {
|
||||
let p = document.getElementById('preview-html')
|
||||
if (p && typeof p.contentWindow.document.body == 'object') {
|
||||
try {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let 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) {
|
||||
let 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
|
||||
}
|
||||
let 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) {
|
||||
let bodyStyles = window.getComputedStyle(document.body, null)
|
||||
let bg = bodyStyles.getPropertyValue('background-color')
|
||||
let txt = bodyStyles.getPropertyValue('color')
|
||||
|
||||
let 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']
|
||||
|
||||
let 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() {
|
||||
var 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)
|
||||
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim
|
||||
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
|
||||
|
||||
// plain www links without https?:// prefix
|
||||
let 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">
|
||||
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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 class="text-body-secondary" v-else>[ 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 class="form-select small tag-selector" v-model="messageTags" 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" :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 class="bi bi-info-circle text-success me-2 link"
|
||||
v-if="message.ListUnsubscribe.HeaderPost != ''" 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 class="bi bi-exclamation-circle text-danger link"
|
||||
v-if="message.ListUnsubscribe.Errors != ''" 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 class="col-md-auto d-none d-md-block text-end mt-md-3"
|
||||
v-if="message.Attachments && message.Attachments.length || message.Inline && message.Inline.length">
|
||||
<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 class="badge rounded-pill text-bg-secondary p-2" v-if="message.Inline.length"
|
||||
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 class="nav nav-tabs my-3 d-print-none" id="nav-tab" role="tablist">
|
||||
<template v-if="message.HTML">
|
||||
<div class="btn-group">
|
||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html"
|
||||
type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml"
|
||||
v-on: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 class="nav-link d-none d-sm-inline" id="nav-html-source-tab" 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 class="nav-link" id="nav-plain-text-tab" 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 class="nav-link" id="nav-headers-tab" 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 class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<div class="dropdown d-xl-none" v-show="hasAnyChecksEnabled">
|
||||
<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 class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||
v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showLinkCheck">
|
||||
<button class="dropdown-item" id="nav-link-check-tab" 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 class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
||||
<small>0</small>
|
||||
</span>
|
||||
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
||||
v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false" v-if="mailbox.showLinkCheck">
|
||||
Link Check
|
||||
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
|
||||
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||
<template v-for="_, key in responsiveSizes">
|
||||
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||
v-on:click="scaleHTMLPreview = key">
|
||||
<i class="bi" :class="'bi-' + key"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content mb-5" id="nav-tabContent">
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
|
||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0" v-if="message.HTML">
|
||||
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
|
||||
tabindex="0" :class="message.HTML == '' ? 'show' : ''">
|
||||
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
||||
<iframe v-if="srcURI" :src="srcURI" v-on:load="initRawIframe" frameborder="0"
|
||||
style="width: 100%; height: 300px"></iframe>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0">
|
||||
<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''" :message="message"
|
||||
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
|
||||
tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<SpamAssassin :message="message" @setSpamScore="(n) => spamScore = n"
|
||||
@set-badge-style="(v) => spamScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0" v-if="mailbox.showLinkCheck">
|
||||
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,84 +1,102 @@
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import ICAL from "ical.js"
|
||||
import dayjs from 'dayjs'
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import ICAL from "ical.js";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
attachments: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
attachments: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
ical: false
|
||||
}
|
||||
ical: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
openAttachment(part, e) {
|
||||
let filename = part.FileName
|
||||
let contentType = part.ContentType
|
||||
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)
|
||||
if (filename.match(/\.ics$/i) || contentType == 'text/calendar') {
|
||||
e.preventDefault()
|
||||
const filename = part.FileName;
|
||||
const contentType = part.ContentType;
|
||||
const href = this.resolve("/api/v1/message/" + this.message.ID + "/part/" + part.PartID);
|
||||
if (filename.match(/\.ics$/i) || contentType === "text/calendar") {
|
||||
e.preventDefault();
|
||||
|
||||
this.get(href, null, (response) => {
|
||||
let comp = new ICAL.Component(ICAL.parse(response.data))
|
||||
let vevent = comp.getFirstSubcomponent('vevent')
|
||||
const comp = new ICAL.Component(ICAL.parse(response.data));
|
||||
const vevent = comp.getFirstSubcomponent("vevent");
|
||||
if (!vevent) {
|
||||
alert('Error parsing ICS file')
|
||||
return
|
||||
alert("Error parsing ICS file");
|
||||
return;
|
||||
}
|
||||
let event = new ICAL.Event(vevent)
|
||||
const event = new ICAL.Event(vevent);
|
||||
|
||||
let summary = {}
|
||||
summary.link = href
|
||||
summary.status = vevent.getFirstPropertyValue('status')
|
||||
summary.url = vevent.getFirstPropertyValue('url')
|
||||
summary.summary = event.summary
|
||||
summary.description = event.description
|
||||
summary.location = event.location
|
||||
summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.end = dayjs(event.endDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.isRecurring = event.isRecurring()
|
||||
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false
|
||||
summary.attendees = []
|
||||
const summary = {};
|
||||
summary.link = href;
|
||||
summary.status = vevent.getFirstPropertyValue("status");
|
||||
summary.url = vevent.getFirstPropertyValue("url");
|
||||
summary.summary = event.summary;
|
||||
summary.description = event.description;
|
||||
summary.location = event.location;
|
||||
summary.start = dayjs(event.startDate).format("ddd, D MMM YYYY, h:mm a");
|
||||
summary.end = dayjs(event.endDate).format("ddd, D MMM YYYY, h:mm a");
|
||||
summary.isRecurring = event.isRecurring();
|
||||
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, "") : false;
|
||||
summary.attendees = [];
|
||||
event.attendees.forEach((a) => {
|
||||
if (a.jCal[1].cn) {
|
||||
summary.attendees.push(a.jCal[1].cn)
|
||||
summary.attendees.push(a.jCal[1].cn);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
|
||||
summary.timezone = vtimezone.getFirstPropertyValue("tzid")
|
||||
})
|
||||
summary.timezone = vtimezone.getFirstPropertyValue("tzid");
|
||||
});
|
||||
|
||||
this.ical = summary
|
||||
this.ical = summary;
|
||||
|
||||
// display modal
|
||||
this.modal('ICSView').show()
|
||||
})
|
||||
this.modal("ICSView").show();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-top pt-4">
|
||||
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px"
|
||||
@click="openAttachment(part, $event)">
|
||||
<img v-if="isImage(part)"
|
||||
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top"
|
||||
alt="">
|
||||
<img v-else
|
||||
<a
|
||||
v-for="part in attachments"
|
||||
:key="part.PartID"
|
||||
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||
class="card attachment float-start me-3 mb-3"
|
||||
target="_blank"
|
||||
style="width: 180px"
|
||||
@click="openAttachment(part, $event)"
|
||||
>
|
||||
<img
|
||||
v-if="isImage(part)"
|
||||
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
|
||||
class="card-img-top"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
|
||||
class="card-img-top" alt="">
|
||||
<div class="icon" v-if="!isImage(part)">
|
||||
class="card-img-top"
|
||||
alt=""
|
||||
/>
|
||||
<div v-if="!isImage(part)" class="icon">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="card-body border-0">
|
||||
@ -87,16 +105,16 @@ export default {
|
||||
<small>{{ getFileSize(part.Size) }}</small>
|
||||
</p>
|
||||
<p class="card-text mb-0 small">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer small border-0 text-center text-truncate">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="ICSView" tabindex="-1" aria-hidden="true">
|
||||
<div id="ICSView" class="modal fade" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@ -106,7 +124,7 @@ export default {
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="ical">
|
||||
<div v-if="ical" class="modal-body">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr v-if="ical.summary">
|
||||
@ -126,7 +144,7 @@ export default {
|
||||
</tr>
|
||||
<tr v-if="ical.status">
|
||||
<th>Status</th>
|
||||
<td> {{ ical.status }}</td>
|
||||
<td>{{ ical.status }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.location">
|
||||
<th>Location</th>
|
||||
@ -134,7 +152,9 @@ export default {
|
||||
</tr>
|
||||
<tr v-if="ical.url">
|
||||
<th>URL</th>
|
||||
<td><a :href="ical.url" target="_blank">{{ ical.url }}</a></td>
|
||||
<td>
|
||||
<a :href="ical.url" target="_blank">{{ ical.url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ical.organizer">
|
||||
<th>Organizer</th>
|
||||
@ -143,7 +163,7 @@ export default {
|
||||
<tr v-if="ical.attendees.length">
|
||||
<th>Attendees</th>
|
||||
<td>
|
||||
<span v-for="(a, i) in ical.attendees">
|
||||
<span v-for="(a, i) in ical.attendees" :key="'attendee_' + i">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ a }}
|
||||
</span>
|
||||
@ -154,12 +174,9 @@ export default {
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<a class="btn btn-primary" target="_blank" :href="ical.link">
|
||||
Download attachment
|
||||
</a>
|
||||
<a class="btn btn-primary" target="_blank" :href="ical.link"> Download attachment </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
40
server/ui-src/components/message/MessageHeaders.vue
Normal file
40
server/ui-src/components/message/MessageHeaders.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
headers: false,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const uri = this.resolve("/api/v1/message/" + this.message.ID + "/headers");
|
||||
this.get(uri, false, (response) => {
|
||||
this.headers = response.data;
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="headers" class="small">
|
||||
<div v-for="(values, k) in headers" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
|
||||
<div class="col-md-4 col-lg-3 col-xl-2 mb-2">
|
||||
<b>{{ k }}</b>
|
||||
</div>
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
861
server/ui-src/components/message/MessageItem.vue
Normal file
861
server/ui-src/components/message/MessageItem.vue
Normal file
@ -0,0 +1,861 @@
|
||||
<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",
|
||||
],
|
||||
FORBID_ATTR: ["script"],
|
||||
});
|
||||
|
||||
// for debugging
|
||||
// this.debugDOMPurify(DOMPurify.removed)
|
||||
|
||||
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>
|
@ -1,19 +1,24 @@
|
||||
<script>
|
||||
import AjaxLoader from '../AjaxLoader.vue'
|
||||
import Tags from "bootstrap5-tags"
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { mailbox } from '../../stores/mailbox'
|
||||
import AjaxLoader from "../AjaxLoader.vue";
|
||||
import Tags from "bootstrap5-tags";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import { mailbox } from "../../stores/mailbox";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
emits: ['delete'],
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["delete"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
@ -21,64 +26,62 @@ export default {
|
||||
deleteAfterRelease: false,
|
||||
mailbox,
|
||||
allAddresses: [],
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
mounted() {
|
||||
let a = []
|
||||
for (let i in this.message.To) {
|
||||
a.push(this.message.To[i].Address)
|
||||
const a = [];
|
||||
for (const i in this.message.To) {
|
||||
a.push(this.message.To[i].Address);
|
||||
}
|
||||
for (let i in this.message.Cc) {
|
||||
a.push(this.message.Cc[i].Address)
|
||||
for (const i in this.message.Cc) {
|
||||
a.push(this.message.Cc[i].Address);
|
||||
}
|
||||
for (let i in this.message.Bcc) {
|
||||
a.push(this.message.Bcc[i].Address)
|
||||
for (const i in this.message.Bcc) {
|
||||
a.push(this.message.Bcc[i].Address);
|
||||
}
|
||||
|
||||
// include only unique email addresses, regardless of casing
|
||||
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map(ad => [ad.toLowerCase(), ad])).values()]))
|
||||
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map((ad) => [ad.toLowerCase(), ad])).values()]));
|
||||
|
||||
this.addresses = this.allAddresses
|
||||
this.addresses = this.allAddresses;
|
||||
},
|
||||
|
||||
methods: {
|
||||
// triggered manually after modal is shown
|
||||
initTags() {
|
||||
Tags.init("select[multiple]")
|
||||
Tags.init("select[multiple]");
|
||||
},
|
||||
|
||||
releaseMessage() {
|
||||
// set timeout to allow for user clicking send before the tag filter has applied the tag
|
||||
window.setTimeout(() => {
|
||||
if (!this.addresses.length) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let data = {
|
||||
To: this.addresses
|
||||
}
|
||||
const data = {
|
||||
To: this.addresses,
|
||||
};
|
||||
|
||||
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => {
|
||||
this.modal("ReleaseModal").hide()
|
||||
this.post(this.resolve("/api/v1/message/" + this.message.ID + "/release"), data, (response) => {
|
||||
this.modal("ReleaseModal").hide();
|
||||
if (this.deleteAfterRelease) {
|
||||
this.$emit('delete')
|
||||
this.$emit("delete");
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" v-if="message">
|
||||
<div id="ReleaseModal" class="modal fade" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||
<div v-if="message" class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
|
||||
<h1 id="AppInfoModalLabel" class="modal-title fs-5">Release email</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@ -86,32 +89,55 @@ export default {
|
||||
<div class="row">
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
|
||||
<div class="col-sm-10">
|
||||
<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text"
|
||||
aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="mailbox.uiConfig.MessageRelay.OverrideFrom">
|
||||
<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="message.From ? message.From.Address : ''">
|
||||
<input
|
||||
v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''"
|
||||
type="text"
|
||||
aria-label="From address"
|
||||
readonly
|
||||
class="form-control-plaintext"
|
||||
:value="mailbox.uiConfig.MessageRelay.OverrideFrom"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
aria-label="From address"
|
||||
readonly
|
||||
class="form-control-plaintext"
|
||||
:value="message.From ? message.From.Address : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class=" col-sm-2 col-form-label text-body-secondary">Subject</label>
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">Subject</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" aria-label="Subject" readonly class="form-control-plaintext"
|
||||
:value="message.Subject">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Subject"
|
||||
readonly
|
||||
class="form-control-plaintext"
|
||||
:value="message.Subject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
|
||||
data-clear-end="true" data-allow-clear="true"
|
||||
data-placeholder="Enter email addresses..." data-add-on-blur="true"
|
||||
<select
|
||||
v-model="addresses"
|
||||
class="form-select tag-selector"
|
||||
multiple
|
||||
data-allow-new="true"
|
||||
data-clear-end="true"
|
||||
data-allow-clear="true"
|
||||
data-placeholder="Enter email addresses..."
|
||||
data-add-on-blur="true"
|
||||
data-badge-style="primary"
|
||||
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
|
||||
data-separator="|,|">
|
||||
data-separator="|,|"
|
||||
>
|
||||
<option value="">Enter email addresses...</option>
|
||||
<!-- you need at least one option with the placeholder -->
|
||||
<option v-for="t in allAddresses" :value="t">{{ t }}</option>
|
||||
<option v-for="t in allAddresses" :key="'address+' + t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Invalid email address</div>
|
||||
</div>
|
||||
@ -119,8 +145,12 @@ export default {
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-10 offset-sm-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" v-model="deleteAfterRelease"
|
||||
id="DeleteAfterRelease">
|
||||
<input
|
||||
id="DeleteAfterRelease"
|
||||
v-model="deleteAfterRelease"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="DeleteAfterRelease">
|
||||
Delete the message after release
|
||||
</label>
|
||||
@ -145,7 +175,8 @@ export default {
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
|
||||
The <code>From</code> email address has been overridden by the relay configuration to
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code>.
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code
|
||||
>.
|
||||
</li>
|
||||
<li class="form-text">
|
||||
SMTP delivery failures will bounce back to
|
||||
@ -155,14 +186,16 @@ export default {
|
||||
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
|
||||
</code>
|
||||
<code v-else>{{ message.ReturnPath }}</code>.
|
||||
<code v-else>{{ message.ReturnPath }}</code
|
||||
>.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
|
||||
v-on:click="releaseMessage">Release</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!addresses.length" @click="releaseMessage">
|
||||
Release
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
165
server/ui-src/components/message/MessageScreenshot.vue
Normal file
165
server/ui-src/components/message/MessageScreenshot.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<script>
|
||||
import AjaxLoader from "../AjaxLoader.vue";
|
||||
import CommonMixins from "../../mixins/CommonMixins";
|
||||
import { domToPng } from "modern-screenshot";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
html: false,
|
||||
loading: 0,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
initScreenshot() {
|
||||
this.loading = 1;
|
||||
// remove base tag, if set
|
||||
let h = this.message.HTML.replace(/<base .*>/im, "");
|
||||
const proxy = this.resolve("/proxy");
|
||||
|
||||
// Outlook hacks - else screenshot returns blank image
|
||||
h = h.replace(/<html [^>]+>/gim, "<html>"); // remove html attributes
|
||||
h = h.replace(/<o:p><\/o:p>/gm, ""); // remove empty `<o:p></o:p>` tags
|
||||
h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>`
|
||||
h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>`
|
||||
|
||||
// update any inline `url(...)` absolute links
|
||||
const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim;
|
||||
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
|
||||
if (typeof p2 === "string") {
|
||||
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`;
|
||||
}
|
||||
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`;
|
||||
});
|
||||
|
||||
// create temporary document to manipulate
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.open();
|
||||
doc.write(h);
|
||||
doc.close();
|
||||
|
||||
// remove any <script> tags
|
||||
const scripts = doc.getElementsByTagName("script");
|
||||
for (const i of scripts) {
|
||||
i.parentNode.removeChild(i);
|
||||
}
|
||||
|
||||
// replace stylesheet links with proxy links
|
||||
const stylesheets = doc.getElementsByTagName("link");
|
||||
for (const i of stylesheets) {
|
||||
const src = i.getAttribute("href");
|
||||
|
||||
if (
|
||||
src &&
|
||||
src.match(/^https?:\/\//i) &&
|
||||
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||
) {
|
||||
i.setAttribute("href", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||
}
|
||||
}
|
||||
|
||||
// replace images with proxy links
|
||||
const images = doc.getElementsByTagName("img");
|
||||
for (const i of images) {
|
||||
const src = i.getAttribute("src");
|
||||
if (
|
||||
src &&
|
||||
src.match(/^https?:\/\//i) &&
|
||||
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||
) {
|
||||
i.setAttribute("src", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||
}
|
||||
}
|
||||
|
||||
// replace background="" attributes with proxy links
|
||||
const backgrounds = doc.querySelectorAll("[background]");
|
||||
for (const i of backgrounds) {
|
||||
const src = i.getAttribute("background");
|
||||
|
||||
if (
|
||||
src &&
|
||||
src.match(/^https?:\/\//i) &&
|
||||
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||
) {
|
||||
// replace with proxy link
|
||||
i.setAttribute("background", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||
}
|
||||
}
|
||||
|
||||
// set html with manipulated document content
|
||||
this.html = new XMLSerializer().serializeToString(doc);
|
||||
},
|
||||
|
||||
// HTML decode function
|
||||
decodeEntities(s) {
|
||||
const e = document.createElement("div");
|
||||
e.innerHTML = s;
|
||||
const str = e.textContent;
|
||||
e.textContent = "";
|
||||
return str;
|
||||
},
|
||||
|
||||
doScreenshot() {
|
||||
let width = document.getElementById("message-view").getBoundingClientRect().width;
|
||||
|
||||
const prev = document.getElementById("preview-html");
|
||||
if (prev && prev.getBoundingClientRect().width) {
|
||||
width = prev.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
if (width < 300) {
|
||||
width = 300;
|
||||
}
|
||||
|
||||
const i = document.getElementById("screenshot-html");
|
||||
|
||||
// set the iframe width
|
||||
i.style.width = width + "px";
|
||||
|
||||
const body = i.contentWindow.document.querySelector("body");
|
||||
|
||||
// take screenshot of iframe
|
||||
domToPng(body, {
|
||||
backgroundColor: "#ffffff",
|
||||
height: i.contentWindow.document.body.scrollHeight + 20,
|
||||
width,
|
||||
}).then((dataUrl) => {
|
||||
const link = document.createElement("a");
|
||||
link.download = this.message.ID + ".png";
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
this.loading = 0;
|
||||
this.html = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
v-if="html"
|
||||
id="screenshot-html"
|
||||
:srcdoc="html"
|
||||
frameborder="0"
|
||||
style="position: absolute; margin-left: -100000px"
|
||||
@load="doScreenshot"
|
||||
>
|
||||
</iframe>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
@ -1,144 +0,0 @@
|
||||
<script>
|
||||
import AjaxLoader from '../AjaxLoader.vue'
|
||||
import CommonMixins from '../../mixins/CommonMixins'
|
||||
import { domToPng } from 'modern-screenshot'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
html: false,
|
||||
loading: 0
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
initScreenshot() {
|
||||
this.loading = 1
|
||||
// remove base tag, if set
|
||||
let h = this.message.HTML.replace(/<base .*>/mi, '')
|
||||
let proxy = this.resolve('/proxy')
|
||||
|
||||
// Outlook hacks - else screenshot returns blank image
|
||||
h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes
|
||||
h = h.replace(/<o:p><\/o:p>/mg, '') // remove empty `<o:p></o:p>` tags
|
||||
h = h.replace(/<o:/mg, '<') // replace `<o:p>` tags with `<p>`
|
||||
h = h.replace(/<\/o:/mg, '</') // replace `</o:p>` tags with `</p>`
|
||||
|
||||
// update any inline `url(...)` absolute links
|
||||
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
|
||||
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
|
||||
if (typeof p2 === 'string') {
|
||||
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
|
||||
}
|
||||
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
|
||||
})
|
||||
|
||||
// create temporary document to manipulate
|
||||
let doc = document.implementation.createHTMLDocument();
|
||||
doc.open()
|
||||
doc.write(h)
|
||||
doc.close()
|
||||
|
||||
// remove any <script> tags
|
||||
let scripts = doc.getElementsByTagName('script')
|
||||
for (let i of scripts) {
|
||||
i.parentNode.removeChild(i)
|
||||
}
|
||||
|
||||
// replace stylesheet links with proxy links
|
||||
let stylesheets = doc.getElementsByTagName('link')
|
||||
for (let i of stylesheets) {
|
||||
let src = i.getAttribute('href')
|
||||
|
||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
||||
}
|
||||
}
|
||||
|
||||
// replace images with proxy links
|
||||
let images = doc.getElementsByTagName('img')
|
||||
for (let i of images) {
|
||||
let src = i.getAttribute('src')
|
||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
||||
}
|
||||
}
|
||||
|
||||
// replace background="" attributes with proxy links
|
||||
let backgrounds = doc.querySelectorAll("[background]")
|
||||
for (let i of backgrounds) {
|
||||
let src = i.getAttribute('background')
|
||||
|
||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||
// replace with proxy link
|
||||
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
||||
}
|
||||
}
|
||||
|
||||
// set html with manipulated document content
|
||||
this.html = new XMLSerializer().serializeToString(doc)
|
||||
},
|
||||
|
||||
// HTML decode function
|
||||
decodeEntities(s) {
|
||||
let e = document.createElement('div')
|
||||
e.innerHTML = s
|
||||
let str = e.textContent
|
||||
e.textContent = ''
|
||||
return str
|
||||
},
|
||||
|
||||
doScreenshot() {
|
||||
let width = document.getElementById('message-view').getBoundingClientRect().width
|
||||
|
||||
let prev = document.getElementById('preview-html')
|
||||
if (prev && prev.getBoundingClientRect().width) {
|
||||
width = prev.getBoundingClientRect().width
|
||||
}
|
||||
|
||||
if (width < 300) {
|
||||
width = 300
|
||||
}
|
||||
|
||||
const i = document.getElementById('screenshot-html')
|
||||
|
||||
// set the iframe width
|
||||
i.style.width = width + 'px'
|
||||
|
||||
let body = i.contentWindow.document.querySelector('body')
|
||||
|
||||
// take screenshot of iframe
|
||||
domToPng(body, {
|
||||
backgroundColor: '#ffffff',
|
||||
height: i.contentWindow.document.body.scrollHeight + 20,
|
||||
width: width,
|
||||
}).then(dataUrl => {
|
||||
const link = document.createElement('a')
|
||||
link.download = this.message.ID + '.png'
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
this.loading = 0
|
||||
this.html = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe v-if="html" :srcdoc="html" v-on:load="doScreenshot" frameborder="0" id="screenshot-html"
|
||||
style="position: absolute; margin-left: -100000px;">
|
||||
</iframe>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
@ -1,52 +1,85 @@
|
||||
<script>
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { VcDonut } from "vue-css-donut-chart";
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setSpamScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setSpamScore", "setBadgeStyle"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
check: false,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.doCheck()
|
||||
computed: {
|
||||
graphSections() {
|
||||
const score = this.check.Score;
|
||||
let p = Math.round((score / 5) * 100);
|
||||
if (p > 100) {
|
||||
p = 100;
|
||||
} else if (p < 0) {
|
||||
p = 0;
|
||||
}
|
||||
|
||||
let c = "#ffc107";
|
||||
if (this.check.IsSpam) {
|
||||
c = "#dc3545";
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: score + " / 5",
|
||||
value: p,
|
||||
color: c,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
scoreColor() {
|
||||
return this.graphSections[0].color;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setSpamScore', false)
|
||||
this.doCheck()
|
||||
this.$emit("setSpamScore", false);
|
||||
this.doCheck();
|
||||
},
|
||||
deep: true
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.doCheck();
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.check = false;
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null)
|
||||
axios
|
||||
.get(this.resolve("/api/v1/message/" + this.message.ID + "/sa-check"), null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.setIcons()
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
this.setIcons();
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@ -54,80 +87,50 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
badgeStyle(ignorePadding = false) {
|
||||
let badgeStyle = 'bg-success'
|
||||
let badgeStyle = "bg-success";
|
||||
if (this.check.Error) {
|
||||
badgeStyle = 'bg-warning text-primary'
|
||||
}
|
||||
else if (this.check.IsSpam) {
|
||||
badgeStyle = 'bg-danger'
|
||||
badgeStyle = "bg-warning text-primary";
|
||||
} else if (this.check.IsSpam) {
|
||||
badgeStyle = "bg-danger";
|
||||
} else if (this.check.Score >= 4) {
|
||||
badgeStyle = 'bg-warning text-primary'
|
||||
badgeStyle = "bg-warning text-primary";
|
||||
}
|
||||
|
||||
if (!ignorePadding && String(this.check.Score).includes('.')) {
|
||||
badgeStyle += " p-1"
|
||||
if (!ignorePadding && String(this.check.Score).includes(".")) {
|
||||
badgeStyle += " p-1";
|
||||
}
|
||||
|
||||
return badgeStyle
|
||||
return badgeStyle;
|
||||
},
|
||||
|
||||
setIcons() {
|
||||
let score = this.check.Score
|
||||
if (this.check.Error && this.check.Error != '') {
|
||||
score = '!'
|
||||
let score = this.check.Score;
|
||||
if (this.check.Error && this.check.Error !== "") {
|
||||
score = "!";
|
||||
}
|
||||
let badgeStyle = this.badgeStyle()
|
||||
this.$emit('setBadgeStyle', badgeStyle)
|
||||
this.$emit('setSpamScore', score)
|
||||
const badgeStyle = this.badgeStyle();
|
||||
this.$emit("setBadgeStyle", badgeStyle);
|
||||
this.$emit("setSpamScore", score);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
graphSections() {
|
||||
let score = this.check.Score
|
||||
let p = Math.round(score / 5 * 100)
|
||||
if (p > 100) {
|
||||
p = 100
|
||||
} else if (p < 0) {
|
||||
p = 0
|
||||
}
|
||||
|
||||
let c = '#ffc107'
|
||||
if (this.check.IsSpam) {
|
||||
c = '#dc3545'
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: score + ' / 5',
|
||||
value: p,
|
||||
color: c
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
scoreColor() {
|
||||
return this.graphSections[0].color
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -145,10 +148,10 @@ export default {
|
||||
|
||||
<template v-if="error || check.Error != ''">
|
||||
<p>Your message could not be checked</p>
|
||||
<div class="alert alert-warning" v-if="error">
|
||||
<div v-if="error" class="alert alert-warning">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div class="alert alert-warning" v-else>
|
||||
<div v-else class="alert alert-warning">
|
||||
There was an error contacting the configured SpamAssassin server: {{ check.Error }}
|
||||
</div>
|
||||
</template>
|
||||
@ -156,11 +159,18 @@ export default {
|
||||
<template v-else-if="check">
|
||||
<div class="row w-100 mt-5">
|
||||
<div class="col-xl-5 mb-2">
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ check.Score }} / 5
|
||||
</h2>
|
||||
<vc-donut
|
||||
:sections="graphSections"
|
||||
background="var(--bs-body-bg)"
|
||||
:size="230"
|
||||
unit="px"
|
||||
:thickness="20"
|
||||
:total="100"
|
||||
:start-angle="270"
|
||||
:auto-adjust-text-size="true"
|
||||
foreground="#198754"
|
||||
>
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">{{ check.Score }} / 5</h2>
|
||||
<div class="text-body mt-2">
|
||||
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
|
||||
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
|
||||
@ -180,7 +190,7 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row w-100 py-2 border-bottom small" v-for="r in check.Rules">
|
||||
<div v-for="r in check.Rules" :key="'rule_' + r.Name" class="row w-100 py-2 border-bottom small">
|
||||
<div class="col-2 col-lg-1">
|
||||
{{ r.Score }}
|
||||
</div>
|
||||
@ -195,25 +205,39 @@ export default {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutSpamAnalysis"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutSpamAnalysisLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutSpamAnalysisLabel">About Spam Analysis</h1>
|
||||
<h1 id="AboutSpamAnalysisLabel" class="modal-title fs-5">About Spam Analysis</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="SpamAnalysisAboutAccordion">
|
||||
<div id="SpamAnalysisAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is Spam Analysis?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit integrates with SpamAssassin to provide you with some insight into the
|
||||
@ -226,13 +250,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
How does the point system work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
|
||||
@ -248,18 +281,27 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
But I don't agree with the results...
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit does not manipulate the results nor determine the "spamminess" of
|
||||
your message. The result is what SpamAssassin returns, and it entirely
|
||||
dependent on how SpamAssassin is set up and optionally trained.
|
||||
Mailpit does not manipulate the results nor determine the "spamminess" of your
|
||||
message. The result is what SpamAssassin returns, and it entirely dependent on
|
||||
how SpamAssassin is set up and optionally trained.
|
||||
</p>
|
||||
<p>
|
||||
This tool is simply provided as an aid to assist you. If you are running your
|
||||
@ -271,20 +313,31 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
Where can I find more information about the triggered rules?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Unfortunately the current <a href="https://spamassassin.apache.org/"
|
||||
target="_blank">SpamAssassin website</a> no longer contains any relative
|
||||
documentation about these, most likely because the rules come from different
|
||||
locations and change often. You will need to search the internet for these
|
||||
yourself.
|
||||
Unfortunately the current
|
||||
<a href="https://spamassassin.apache.org/" target="_blank"
|
||||
>SpamAssassin website</a
|
||||
>
|
||||
no longer contains any relative documentation about these, most likely because
|
||||
the rules come from different locations and change often. You will need to
|
||||
search the internet for these yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user