1
0
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:
Ralph Slooten
2025-06-20 23:26:06 +12:00
parent 7dee371721
commit 3fff79e29f
45 changed files with 8690 additions and 3458 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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> &amp;
</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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> &amp;
</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>

View File

@ -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> &nbsp;
</span>
&nbsp;
<span class="text-nowrap">
<i class="bi bi-circle-fill text-warning"></i>
{{ round2dm(summary.Total.Partial) }}% partially supported
</span> &nbsp;
</span>
&nbsp;
<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">

View File

@ -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>

View File

@ -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 &amp; HTML for unique links, testing the response status codes.
This includes links to images and remote CSS stylesheets.
Link check scans your email text &amp; 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 &amp; 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">

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.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">
&lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body">
{{ t.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body">
{{ t.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body">
{{ t.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body-secondary">
{{ t.Address }}
</a>&gt;
</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">
&lt;<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
{{ message.ReturnPath }}
</a>&gt;
</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>
&lt;{{ u }}&gt;
</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>

View File

@ -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>

View 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>

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.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">
&lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }} </a
>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a
>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body-secondary"> {{ t.Address }} </a
>&gt;
</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">
&lt;<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
{{ message.ReturnPath }} </a
>&gt;
</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>
&lt;{{ u }}&gt;
</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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>