mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 00:37:01 +00:00
Chore: Apply linting to all JavaScript/Vue files with eslint & prettier
This commit is contained in:
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Not within the scope of Prettier
|
||||||
|
**/*.yml
|
||||||
|
**/*.yaml
|
||||||
|
**/*.json
|
||||||
|
**/*.md
|
||||||
|
**/*.css
|
||||||
|
**/*.html
|
||||||
|
**/*.scss
|
||||||
|
composer.lock
|
||||||
@@ -1,44 +1,39 @@
|
|||||||
import * as esbuild from 'esbuild'
|
import * as esbuild from "esbuild";
|
||||||
import pluginVue from 'esbuild-plugin-vue-next'
|
import pluginVue from "esbuild-plugin-vue-next";
|
||||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
import { sassPlugin } from "esbuild-sass-plugin";
|
||||||
|
|
||||||
const doWatch = process.env.WATCH == 'true' ? true : false;
|
const doWatch = process.env.WATCH === "true";
|
||||||
const doMinify = process.env.MINIFY == 'true' ? true : false;
|
const doMinify = process.env.MINIFY === "true";
|
||||||
|
|
||||||
const ctx = await esbuild.context(
|
const ctx = await esbuild.context({
|
||||||
{
|
entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"],
|
||||||
entryPoints: [
|
bundle: true,
|
||||||
"server/ui-src/app.js",
|
minify: doMinify,
|
||||||
"server/ui-src/docs.js"
|
sourcemap: false,
|
||||||
],
|
define: {
|
||||||
bundle: true,
|
__VUE_OPTIONS_API__: "true",
|
||||||
minify: doMinify,
|
__VUE_PROD_DEVTOOLS__: "false",
|
||||||
sourcemap: false,
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
|
||||||
define: {
|
},
|
||||||
'__VUE_OPTIONS_API__': 'true',
|
outdir: "server/ui/dist/",
|
||||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
plugins: [
|
||||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
pluginVue(),
|
||||||
},
|
sassPlugin({
|
||||||
outdir: "server/ui/dist/",
|
silenceDeprecations: ["import"],
|
||||||
plugins: [
|
quietDeps: true,
|
||||||
pluginVue(),
|
}),
|
||||||
sassPlugin({
|
],
|
||||||
silenceDeprecations: ['import'],
|
loader: {
|
||||||
quietDeps: true,
|
".svg": "file",
|
||||||
})
|
".woff": "file",
|
||||||
],
|
".woff2": "file",
|
||||||
loader: {
|
},
|
||||||
".svg": "file",
|
logLevel: "info",
|
||||||
".woff": "file",
|
});
|
||||||
".woff2": "file",
|
|
||||||
},
|
|
||||||
logLevel: "info"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (doWatch) {
|
if (doWatch) {
|
||||||
await ctx.watch()
|
await ctx.watch();
|
||||||
} else {
|
} else {
|
||||||
await ctx.rebuild()
|
await ctx.rebuild();
|
||||||
ctx.dispose()
|
ctx.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
34
eslint.config.js
Normal file
34
eslint.config.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||||
|
import neostandard, { resolveIgnoresFromGitignore } from "neostandard";
|
||||||
|
import vue from "eslint-plugin-vue";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
/* Baseline JS rules, provided by Neostandard */
|
||||||
|
...neostandard({
|
||||||
|
/* Allows references to browser APIs like `document` */
|
||||||
|
env: ["browser"],
|
||||||
|
|
||||||
|
/* We rely on .gitignore to avoid running against dist / dependency files */
|
||||||
|
ignores: resolveIgnoresFromGitignore(),
|
||||||
|
|
||||||
|
/* Disables a range of style-related rules, as we use Prettier for that */
|
||||||
|
noStyle: true,
|
||||||
|
|
||||||
|
/* Ensures we only lint JS and Vue files */
|
||||||
|
files: ["**/*.js", "**/*.vue"],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/* Vue-specific rules */
|
||||||
|
...vue.configs["flat/recommended"],
|
||||||
|
|
||||||
|
/* Prettier is responsible for formatting, so this disables any conflicting rules */
|
||||||
|
eslintConfigPrettier,
|
||||||
|
|
||||||
|
/* Our custom rules */
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
/* We prefer arrow functions for tidiness and consistency */
|
||||||
|
"prefer-arrow-callback": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
4338
package-lock.json
generated
4338
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "mailpit",
|
"name": "mailpit",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "MINIFY=true node esbuild.config.mjs",
|
"build": "MINIFY=true node esbuild.config.mjs",
|
||||||
"watch": "WATCH=true node esbuild.config.mjs",
|
"watch": "WATCH=true node esbuild.config.mjs",
|
||||||
"package": "MINIFY=true node esbuild.config.mjs",
|
"package": "MINIFY=true node esbuild.config.mjs",
|
||||||
"update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json"
|
"update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json",
|
||||||
|
"lint": "eslint --max-warnings 0 && prettier -c .",
|
||||||
|
"lint-fix": "eslint --fix && prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.2.1",
|
"axios": "^1.2.1",
|
||||||
@@ -33,6 +36,16 @@
|
|||||||
"@vue/compiler-sfc": "^3.2.37",
|
"@vue/compiler-sfc": "^3.2.37",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"esbuild-plugin-vue-next": "^0.1.4",
|
"esbuild-plugin-vue-next": "^0.1.4",
|
||||||
"esbuild-sass-plugin": "^3.0.0"
|
"esbuild-sass-plugin": "^3.0.0",
|
||||||
|
"eslint": "^9.29.0",
|
||||||
|
"eslint-config-prettier": "^10.1.5",
|
||||||
|
"eslint-plugin-vue": "^10.2.0",
|
||||||
|
"neostandard": "^0.12.1",
|
||||||
|
"prettier": "^3.5.3"
|
||||||
|
},
|
||||||
|
"prettier":{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": true,
|
||||||
|
"printWidth": 120
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,41 @@
|
|||||||
<script>
|
<script>
|
||||||
import CommonMixins from './mixins/CommonMixins'
|
import CommonMixins from "./mixins/CommonMixins";
|
||||||
import Favicon from './components/Favicon.vue'
|
import Favicon from "./components/AppFavicon.vue";
|
||||||
import AppBadge from './components/AppBadge.vue'
|
import AppBadge from "./components/AppBadge.vue";
|
||||||
import Notifications from './components/Notifications.vue'
|
import Notifications from "./components/AppNotifications.vue";
|
||||||
import EditTags from './components/EditTags.vue'
|
import EditTags from "./components/EditTags.vue";
|
||||||
import { mailbox } from "./stores/mailbox"
|
import { mailbox } from "./stores/mailbox";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Favicon,
|
Favicon,
|
||||||
AppBadge,
|
AppBadge,
|
||||||
Notifications,
|
Notifications,
|
||||||
EditTags
|
EditTags,
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeMount() {
|
mixins: [CommonMixins],
|
||||||
// load global config
|
|
||||||
this.get(this.resolve('/api/v1/webui'), false, function (response) {
|
|
||||||
mailbox.uiConfig = response.data
|
|
||||||
|
|
||||||
if (mailbox.uiConfig.Label) {
|
|
||||||
document.title = document.title + ' - ' + mailbox.uiConfig.Label
|
|
||||||
} else {
|
|
||||||
document.title = document.title + ' - ' + location.hostname
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
$route(to, from) {
|
$route(to, from) {
|
||||||
// hide mobile menu on URL change
|
// hide mobile menu on URL change
|
||||||
this.hideNav()
|
this.hideNav();
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
beforeMount() {
|
||||||
|
// load global config
|
||||||
|
this.get(this.resolve("/api/v1/webui"), false, (response) => {
|
||||||
|
mailbox.uiConfig = response.data;
|
||||||
|
|
||||||
|
if (mailbox.uiConfig.Label) {
|
||||||
|
document.title = document.title + " - " + mailbox.uiConfig.Label;
|
||||||
|
} else {
|
||||||
|
document.title = document.title + " - " + location.hostname;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import App from './App.vue'
|
import App from "./App.vue";
|
||||||
import router from './router'
|
import router from "./router";
|
||||||
import { createApp } from 'vue'
|
import { createApp } from "vue";
|
||||||
import mitt from 'mitt';
|
import mitt from "mitt";
|
||||||
|
|
||||||
import './assets/styles.scss'
|
import "./assets/styles.scss";
|
||||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
import "bootstrap-icons/font/bootstrap-icons.scss";
|
||||||
import 'bootstrap'
|
import "bootstrap";
|
||||||
import 'vue-css-donut-chart/src/styles/main.css'
|
import "vue-css-donut-chart/src/styles/main.css";
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App);
|
||||||
|
|
||||||
// Global event bus used to subscribe to websocket events
|
// Global event bus used to subscribe to websocket events
|
||||||
// such as message deletes, updates & truncation.
|
// such as message deletes, updates & truncation.
|
||||||
const eventBus = mitt()
|
const eventBus = mitt();
|
||||||
app.provide('eventBus', eventBus)
|
app.provide("eventBus", eventBus);
|
||||||
|
|
||||||
app.use(router)
|
app.use(router);
|
||||||
app.mount('#app')
|
app.mount("#app");
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
loading: Number,
|
loading: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="d-flex justify-content-center align-items-center h-100">
|
||||||
<div class="spinner-border text-muted" role="status">
|
<div class="spinner-border text-muted" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
|||||||
@@ -1,75 +1,83 @@
|
|||||||
<script>
|
<script>
|
||||||
import AjaxLoader from './AjaxLoader.vue'
|
import AjaxLoader from "./AjaxLoader.vue";
|
||||||
import Settings from '../components/Settings.vue'
|
import Settings from "./AppSettings.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
Settings,
|
Settings,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
modals: {
|
modals: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
loadInfo() {
|
loadInfo() {
|
||||||
this.get(this.resolve('/api/v1/info'), false, (response) => {
|
this.get(this.resolve("/api/v1/info"), false, (response) => {
|
||||||
mailbox.appInfo = response.data
|
mailbox.appInfo = response.data;
|
||||||
this.modal('AppInfoModal').show()
|
this.modal("AppInfoModal").show();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
requestNotifications() {
|
requestNotifications() {
|
||||||
// check if the browser supports notifications
|
// check if the browser supports notifications
|
||||||
if (!("Notification" in window)) {
|
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
|
// we need to ask the user for permission
|
||||||
else if (Notification.permission !== "denied") {
|
else if (Notification.permission !== "denied") {
|
||||||
Notification.requestPermission().then((permission) => {
|
Notification.requestPermission().then((permission) => {
|
||||||
if (permission === "granted") {
|
if (permission === "granted") {
|
||||||
mailbox.notificationsEnabled = true
|
mailbox.notificationsEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.modal('EnableNotificationsModal').hide()
|
this.modal("EnableNotificationsModal").hide();
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="!modals">
|
<template v-if="!modals">
|
||||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
<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>
|
<i class="bi bi-info-circle-fill me-1"></i>
|
||||||
About
|
About
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
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>
|
<i class="bi bi-gear-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
|
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled"
|
||||||
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>
|
<i class="bi bi-bell"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,12 +85,17 @@ export default {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel"
|
<div
|
||||||
aria-hidden="true">
|
id="AppInfoModal"
|
||||||
|
class="modal modal-xl fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="AppInfoModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div class="modal-dialog">
|
<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">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="AppInfoModalLabel">
|
<h5 id="AppInfoModalLabel" class="modal-title">
|
||||||
Mailpit
|
Mailpit
|
||||||
<code>({{ mailbox.appInfo.Version }})</code>
|
<code>({{ mailbox.appInfo.Version }})</code>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -92,19 +105,27 @@ export default {
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-xl-6">
|
<div class="col-xl-6">
|
||||||
<div v-if="mailbox.appInfo.LatestVersion != 'disabled'">
|
<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="col">
|
||||||
<div class="alert alert-warning mb-3">
|
<div class="alert alert-warning mb-3">
|
||||||
There might be a newer version available. The check failed.
|
There might be a newer version available. The check failed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-3"
|
<div
|
||||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
|
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
|
||||||
|
class="row g-3"
|
||||||
|
>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a class="btn btn-warning d-block mb-3"
|
<a
|
||||||
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
|
class="btn btn-warning d-block mb-3"
|
||||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
|
:href="
|
||||||
|
'https://github.com/axllent/mailpit/releases/tag/' +
|
||||||
|
mailbox.appInfo.LatestVersion
|
||||||
|
"
|
||||||
|
>
|
||||||
|
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
|
||||||
|
available.
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,15 +138,21 @@ export default {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit"
|
<a
|
||||||
target="_blank">
|
class="btn btn-primary w-100"
|
||||||
|
href="https://github.com/axllent/mailpit"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<i class="bi bi-github"></i>
|
<i class="bi bi-github"></i>
|
||||||
Github
|
Github
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<a class="btn btn-primary w-100" href="https://mailpit.axllent.org/docs/"
|
<a
|
||||||
target="_blank">
|
class="btn btn-primary w-100"
|
||||||
|
href="https://mailpit.axllent.org/docs/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
Documentation
|
Documentation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +160,8 @@ export default {
|
|||||||
<div class="card border-secondary text-center">
|
<div class="card border-secondary text-center">
|
||||||
<div class="card-header">Database size</div>
|
<div class="card-header">Database size</div>
|
||||||
<div class="card-body text-muted">
|
<div class="card-body text-muted">
|
||||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
<h5 class="card-title">
|
||||||
|
{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,8 +182,7 @@ export default {
|
|||||||
<div class="card border-secondary h-100">
|
<div class="card border-secondary h-100">
|
||||||
<div class="card-header h4">
|
<div class="card-header h4">
|
||||||
Runtime statistics
|
Runtime statistics
|
||||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
<button class="btn btn-sm btn-outline-secondary float-end" @click="loadInfo()">
|
||||||
v-on:click="loadInfo()">
|
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,46 +190,38 @@ export default {
|
|||||||
<table class="table table-sm table-borderless mb-0">
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>Mailpit up since</td>
|
||||||
Mailpit up since
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
|
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>Messages deleted</td>
|
||||||
Messages deleted
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}
|
{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>SMTP messages accepted</td>
|
||||||
SMTP messages accepted
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
({{
|
({{
|
||||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
getFileSize(
|
||||||
|
mailbox.appInfo.RuntimeStats.SMTPAcceptedSize,
|
||||||
|
)
|
||||||
}})
|
}})
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>SMTP messages rejected</td>
|
||||||
SMTP messages rejected
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}
|
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="mailbox.uiConfig.DuplicatesIgnored">
|
<tr v-if="mailbox.uiConfig.DuplicatesIgnored">
|
||||||
<td>
|
<td>SMTP messages ignored</td>
|
||||||
SMTP messages ignored
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}
|
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}
|
||||||
</td>
|
</td>
|
||||||
@@ -210,12 +229,9 @@ export default {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
@@ -224,26 +240,30 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1"
|
<div
|
||||||
aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
id="EnableNotificationsModal"
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="EnableNotificationsModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
|
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
|
||||||
<p>
|
<p>
|
||||||
Note that your browser will ask you for confirmation when you click
|
Note that your browser will ask you for confirmation when you click
|
||||||
<code>enable notifications</code>,
|
<code>enable notifications</code>, and that you must have Mailpit open in a browser tab to
|
||||||
and that you must have Mailpit open in a browser tab to be able to receive the
|
be able to receive the notifications.
|
||||||
notifications.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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
|
Enable notifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,59 +1,57 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mailbox } from '../stores/mailbox.js'
|
import { mailbox } from "../stores/mailbox.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
updating: false,
|
updating: false,
|
||||||
needsUpdate: false,
|
needsUpdate: false,
|
||||||
timeout: 500,
|
timeout: 500,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
mailboxUnread() {
|
mailboxUnread() {
|
||||||
return mailbox.unread
|
return mailbox.unread;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
mailboxUnread: {
|
mailboxUnread: {
|
||||||
handler() {
|
handler() {
|
||||||
if (this.updating) {
|
if (this.updating) {
|
||||||
this.needsUpdate = true
|
this.needsUpdate = true;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleUpdate()
|
this.scheduleUpdate();
|
||||||
},
|
},
|
||||||
immediate: true
|
immediate: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
scheduleUpdate() {
|
scheduleUpdate() {
|
||||||
this.updating = true
|
this.updating = true;
|
||||||
this.needsUpdate = false
|
this.needsUpdate = false;
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.updateAppBadge()
|
this.updateAppBadge();
|
||||||
this.updating = false
|
this.updating = false;
|
||||||
|
|
||||||
if (this.needsUpdate) {
|
if (this.needsUpdate) {
|
||||||
this.scheduleUpdate()
|
this.scheduleUpdate();
|
||||||
}
|
}
|
||||||
}, this.timeout)
|
}, this.timeout);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAppBadge() {
|
updateAppBadge() {
|
||||||
if (!('setAppBadge' in navigator)) {
|
if (!("setAppBadge" in navigator)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.setAppBadge(this.mailboxUnread)
|
navigator.setAppBadge(this.mailboxUnread);
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template></template>
|
|
||||||
|
|||||||
116
server/ui-src/components/AppFavicon.vue
Normal file
116
server/ui-src/components/AppFavicon.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script>
|
||||||
|
import { mailbox } from "../stores/mailbox.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
favicon: false,
|
||||||
|
iconPath: false,
|
||||||
|
iconTextColor: "#ffffff",
|
||||||
|
iconBgColor: "#dd0000",
|
||||||
|
iconFontSize: 40,
|
||||||
|
iconProcessing: false,
|
||||||
|
iconTimeout: 500,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
count() {
|
||||||
|
let i = mailbox.unread;
|
||||||
|
if (i > 1000) {
|
||||||
|
i = Math.floor(i / 1000) + "k";
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
count() {
|
||||||
|
if (!this.favicon || this.iconProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.iconProcessing = true;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.icoUpdate();
|
||||||
|
}, this.iconTimeout);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.favicon = document.head.querySelector('link[rel="icon"]');
|
||||||
|
if (this.favicon) {
|
||||||
|
this.iconPath = this.favicon.href;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async icoUpdate() {
|
||||||
|
if (!this.favicon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.count) {
|
||||||
|
this.iconProcessing = false;
|
||||||
|
this.favicon.href = this.iconPath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fontSize = this.iconFontSize;
|
||||||
|
// Draw badge text
|
||||||
|
let textPaddingX = 7;
|
||||||
|
const textPaddingY = 3;
|
||||||
|
|
||||||
|
const strlen = this.count.toString().length;
|
||||||
|
|
||||||
|
if (strlen > 2) {
|
||||||
|
// if text >= 3 characters then reduce size and padding
|
||||||
|
textPaddingX = 4;
|
||||||
|
fontSize = strlen > 3 ? 30 : 36;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 64;
|
||||||
|
canvas.height = 64;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
// Draw base icon
|
||||||
|
const icon = new Image();
|
||||||
|
icon.src = this.iconPath;
|
||||||
|
await icon.decode();
|
||||||
|
|
||||||
|
ctx.drawImage(icon, 0, 0, 64, 64);
|
||||||
|
|
||||||
|
// Measure text
|
||||||
|
ctx.font = `${fontSize}px Arial, sans-serif`;
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
const textMetrics = ctx.measureText(this.count);
|
||||||
|
|
||||||
|
// Draw badge
|
||||||
|
const paddingX = 7;
|
||||||
|
const paddingY = 4;
|
||||||
|
const cornerRadius = 8;
|
||||||
|
|
||||||
|
const width = textMetrics.width + paddingX * 2;
|
||||||
|
const height = fontSize + paddingY * 2;
|
||||||
|
const x = canvas.width - width;
|
||||||
|
const y = canvas.height - height - 1;
|
||||||
|
|
||||||
|
ctx.fillStyle = this.iconBgColor;
|
||||||
|
ctx.roundRect(x, y, width, height, cornerRadius);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = this.iconTextColor;
|
||||||
|
ctx.fillText(this.count, canvas.width - textPaddingX, canvas.height - fontSize - textPaddingY);
|
||||||
|
|
||||||
|
this.iconProcessing = false;
|
||||||
|
|
||||||
|
this.favicon.href = canvas.toDataURL("image/png");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
289
server/ui-src/components/AppNotifications.vue
Normal file
289
server/ui-src/components/AppNotifications.vue
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
|
import { Toast } from "bootstrap";
|
||||||
|
import { mailbox } from "../stores/mailbox";
|
||||||
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pagination,
|
||||||
|
mailbox,
|
||||||
|
toastMessage: false,
|
||||||
|
reconnectRefresh: false,
|
||||||
|
socketURI: false,
|
||||||
|
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
||||||
|
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||||
|
pauseNotifications: false, // prevent spamming
|
||||||
|
version: false,
|
||||||
|
clientErrors: [], // errors received via websocket
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
const d = document.getElementById("app");
|
||||||
|
if (d) {
|
||||||
|
this.version = d.dataset.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`);
|
||||||
|
|
||||||
|
this.socketBreakReset();
|
||||||
|
this.connect();
|
||||||
|
|
||||||
|
mailbox.notificationsSupported =
|
||||||
|
window.isSecureContext && "Notification" in window && Notification.permission !== "denied";
|
||||||
|
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission === "granted";
|
||||||
|
|
||||||
|
this.errorNotificationCron();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
// websocket connect
|
||||||
|
connect() {
|
||||||
|
const ws = new WebSocket(this.socketURI);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(e.data);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// new messages
|
||||||
|
if (response.Type === "new" && response.Data) {
|
||||||
|
this.eventBus.emit("new", response.Data);
|
||||||
|
|
||||||
|
for (const i in response.Data.Tags) {
|
||||||
|
if (
|
||||||
|
mailbox.tags.findIndex((e) => {
|
||||||
|
return e.toLowerCase() === response.Data.Tags[i].toLowerCase();
|
||||||
|
}) < 0
|
||||||
|
) {
|
||||||
|
mailbox.tags.push(response.Data.Tags[i]);
|
||||||
|
mailbox.tags.sort((a, b) => {
|
||||||
|
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send notifications
|
||||||
|
if (!this.pauseNotifications) {
|
||||||
|
this.pauseNotifications = true;
|
||||||
|
const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]";
|
||||||
|
this.browserNotify("New mail from: " + from, response.Data.Subject);
|
||||||
|
this.setMessageToast(response.Data);
|
||||||
|
// delay notifications by 2s
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.pauseNotifications = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} else if (response.Type === "prune") {
|
||||||
|
// messages have been deleted, reload messages to adjust
|
||||||
|
window.scrollInPlace = true;
|
||||||
|
mailbox.refresh = true; // trigger refresh
|
||||||
|
window.setTimeout(() => {
|
||||||
|
mailbox.refresh = false;
|
||||||
|
}, 500);
|
||||||
|
this.eventBus.emit("prune");
|
||||||
|
} else if (response.Type === "stats" && response.Data) {
|
||||||
|
// refresh mailbox stats
|
||||||
|
mailbox.total = response.Data.Total;
|
||||||
|
mailbox.unread = response.Data.Unread;
|
||||||
|
|
||||||
|
// detect version updated, refresh is needed
|
||||||
|
if (this.version !== response.Data.Version) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} else if (response.Type === "delete" && response.Data) {
|
||||||
|
// broadcast for components
|
||||||
|
this.eventBus.emit("delete", response.Data);
|
||||||
|
} else if (response.Type === "update" && response.Data) {
|
||||||
|
// broadcast for components
|
||||||
|
this.eventBus.emit("update", response.Data);
|
||||||
|
} else if (response.Type === "truncate") {
|
||||||
|
// broadcast for components
|
||||||
|
this.eventBus.emit("truncate");
|
||||||
|
} else if (response.Type === "error") {
|
||||||
|
// broadcast for components
|
||||||
|
this.addClientError(response.Data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
mailbox.connected = true;
|
||||||
|
this.socketLastConnection = Date.now();
|
||||||
|
if (this.reconnectRefresh) {
|
||||||
|
this.reconnectRefresh = false;
|
||||||
|
mailbox.refresh = true; // trigger refresh
|
||||||
|
window.setTimeout(() => {
|
||||||
|
mailbox.refresh = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (e) => {
|
||||||
|
if (this.socketLastConnection === 0) {
|
||||||
|
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
||||||
|
console.log("Unable to connect to websocket, disabling websocket support");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mailbox.connected) {
|
||||||
|
// count disconnections
|
||||||
|
this.socketBreaks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// set disconnected state
|
||||||
|
mailbox.connected = false;
|
||||||
|
|
||||||
|
if (this.socketBreaks > 3) {
|
||||||
|
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
||||||
|
// something is not working right on their end, see issue #319
|
||||||
|
console.log("Unstable websocket connection, disabling websocket support");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - this.socketLastConnection > 5000) {
|
||||||
|
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
||||||
|
this.reconnectRefresh = true;
|
||||||
|
} else {
|
||||||
|
this.reconnectRefresh = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect(); // reconnect
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function () {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
socketBreakReset() {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.socketBreaks = 0;
|
||||||
|
this.socketBreakReset();
|
||||||
|
}, 15000);
|
||||||
|
},
|
||||||
|
|
||||||
|
browserNotify(title, message) {
|
||||||
|
if (!("Notification" in window)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
const options = {
|
||||||
|
body: message,
|
||||||
|
icon: this.resolve("/notification.png"),
|
||||||
|
};
|
||||||
|
|
||||||
|
(() => new Notification(title, options))();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setMessageToast(m) {
|
||||||
|
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||||
|
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toastMessage = m;
|
||||||
|
|
||||||
|
const el = document.getElementById("messageToast");
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener("hidden.bs.toast", () => {
|
||||||
|
this.toastMessage = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.getOrCreateInstance(el).show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeToast() {
|
||||||
|
const el = document.getElementById("messageToast");
|
||||||
|
if (el) {
|
||||||
|
Toast.getOrCreateInstance(el).hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addClientError(d) {
|
||||||
|
d.expire = Date.now() + 5000; // expire after 5s
|
||||||
|
this.clientErrors.push(d);
|
||||||
|
},
|
||||||
|
|
||||||
|
errorNotificationCron() {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.clientErrors.forEach((err, idx) => {
|
||||||
|
if (err.expire < Date.now()) {
|
||||||
|
this.clientErrors.splice(idx, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.errorNotificationCron();
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
<div
|
||||||
|
v-for="(error, i) in clientErrors"
|
||||||
|
:key="'error_' + i"
|
||||||
|
class="toast show"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<div class="toast-header">
|
||||||
|
<svg
|
||||||
|
class="bd-placeholder-img rounded me-2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<rect width="100%" height="100%" :fill="error.Level === 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||||
|
</svg>
|
||||||
|
<strong class="me-auto">{{ error.Type }}</strong>
|
||||||
|
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
{{ error.Message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div v-if="toastMessage" class="toast-header">
|
||||||
|
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||||
|
<strong class="me-auto">
|
||||||
|
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||||
|
</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-body">
|
||||||
|
<div>
|
||||||
|
<RouterLink
|
||||||
|
:to="'/view/' + toastMessage.ID"
|
||||||
|
class="d-block text-truncate text-body-secondary"
|
||||||
|
@click="closeToast"
|
||||||
|
>
|
||||||
|
<template v-if="toastMessage.Subject !== ''">{{ toastMessage.Subject }}</template>
|
||||||
|
<template v-else> [ no subject ] </template>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
381
server/ui-src/components/AppSettings.vue
Normal file
381
server/ui-src/components/AppSettings.vue
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
|
import Tags from "bootstrap5-tags";
|
||||||
|
import timezones from "timezones-list";
|
||||||
|
import { mailbox } from "../stores/mailbox";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mailbox,
|
||||||
|
theme: localStorage.getItem("theme") ? localStorage.getItem("theme") : "auto",
|
||||||
|
timezones,
|
||||||
|
chaosConfig: false,
|
||||||
|
chaosUpdated: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
theme(v) {
|
||||||
|
if (v === "auto") {
|
||||||
|
localStorage.removeItem("theme");
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("theme", v);
|
||||||
|
}
|
||||||
|
this.setTheme();
|
||||||
|
},
|
||||||
|
|
||||||
|
chaosConfig: {
|
||||||
|
handler() {
|
||||||
|
this.chaosUpdated = true;
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"mailbox.skipConfirmations"(v) {
|
||||||
|
if (v) {
|
||||||
|
localStorage.setItem("skip-confirmations", "true");
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("skip-confirmations");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.setTheme();
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Tags.init("select.tz");
|
||||||
|
});
|
||||||
|
|
||||||
|
mailbox.skipConfirmations = !!localStorage.getItem("skip-confirmations");
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
setTheme() {
|
||||||
|
if (this.theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", this.theme);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadChaos() {
|
||||||
|
this.get(this.resolve("/api/v1/chaos"), null, (response) => {
|
||||||
|
this.chaosConfig = response.data;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.chaosUpdated = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveChaos() {
|
||||||
|
this.put(this.resolve("/api/v1/chaos"), this.chaosConfig, (response) => {
|
||||||
|
this.chaosConfig = response.data;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.chaosUpdated = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
id="SettingsModal"
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="SettingsModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-bs-keyboard="false"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 id="SettingsModalLabel" class="modal-title">Mailpit settings</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ul v-if="mailbox.uiConfig.ChaosEnabled" id="myTab" class="nav nav-tabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
id="ui-tab"
|
||||||
|
class="nav-link active"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#ui-tab-pane"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="ui-tab-pane"
|
||||||
|
aria-selected="true"
|
||||||
|
>
|
||||||
|
Web UI
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
id="chaos-tab"
|
||||||
|
class="nav-link"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#chaos-tab-pane"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="chaos-tab-pane"
|
||||||
|
aria-selected="false"
|
||||||
|
@click="loadChaos"
|
||||||
|
>
|
||||||
|
Chaos
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<div
|
||||||
|
id="ui-tab-pane"
|
||||||
|
class="tab-pane fade show active"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="ui-tab"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="my-3">
|
||||||
|
<label for="theme" class="form-label">Mailpit theme</label>
|
||||||
|
<select id="theme" v-model="theme" class="form-select">
|
||||||
|
<option value="auto">Auto (detect from browser)</option>
|
||||||
|
<option value="light">Light theme</option>
|
||||||
|
<option value="dark">Dark theme</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||||
|
<select
|
||||||
|
id="timezone"
|
||||||
|
v-model="mailbox.timeZone"
|
||||||
|
class="form-select tz"
|
||||||
|
data-allow-same="true"
|
||||||
|
>
|
||||||
|
<option disabled hidden value="">Select a timezone...</option>
|
||||||
|
<option v-for="t in timezones" :key="t" :value="t.tzCode">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input
|
||||||
|
id="tagColors"
|
||||||
|
v-model="mailbox.showTagColors"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="tagColors">
|
||||||
|
Use auto-generated tag colors
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input
|
||||||
|
id="htmlCheck"
|
||||||
|
v-model="mailbox.showHTMLCheck"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="htmlCheck">
|
||||||
|
Show HTML check message tab
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input
|
||||||
|
id="linkCheck"
|
||||||
|
v-model="mailbox.showLinkCheck"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="linkCheck">
|
||||||
|
Show link check message tab
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="mailbox.uiConfig.SpamAssassin" class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input
|
||||||
|
id="spamCheck"
|
||||||
|
v-model="mailbox.showSpamCheck"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="spamCheck">
|
||||||
|
Show spam check message tab
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input
|
||||||
|
id="skip-confirmations"
|
||||||
|
v-model="mailbox.skipConfirmations"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="skip-confirmations">
|
||||||
|
Skip
|
||||||
|
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
||||||
|
<code>Delete all</code> &
|
||||||
|
</template>
|
||||||
|
<code>Mark all read</code> confirmation dialogs
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="mailbox.uiConfig.ChaosEnabled"
|
||||||
|
id="chaos-tab-pane"
|
||||||
|
class="tab-pane fade"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="chaos-tab"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<p class="my-3">
|
||||||
|
<b>Chaos</b> allows you to set random SMTP failures and response codes at various stages
|
||||||
|
in a SMTP transaction to test application resilience (<a
|
||||||
|
href="https://mailpit.axllent.org/docs/integration/chaos/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
see documentation </a
|
||||||
|
>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<code>Response code</code> is the SMTP error code returned by the server if this
|
||||||
|
error is triggered. Error codes must range between 400 and 599.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>Error probability</code> is the % chance that the error will occur per message
|
||||||
|
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
||||||
|
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
||||||
|
messages received.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template v-if="chaosConfig">
|
||||||
|
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label>Trigger: <code>Sender</code></label>
|
||||||
|
<div class="form-text">
|
||||||
|
Trigger an error response based on the sender (From / Sender).
|
||||||
|
</div>
|
||||||
|
<div class="row mt-1">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label"> Response code </label>
|
||||||
|
<input
|
||||||
|
v-model.number="chaosConfig.Sender.ErrorCode"
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
min="400"
|
||||||
|
max="599"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">
|
||||||
|
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="chaosConfig.Sender.Probability"
|
||||||
|
type="range"
|
||||||
|
class="form-range mt-1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label>Trigger: <code>Recipient</code></label>
|
||||||
|
<div class="form-text">
|
||||||
|
Trigger an error response based on the recipients (To, Cc, Bcc).
|
||||||
|
</div>
|
||||||
|
<div class="row mt-1">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label"> Response code </label>
|
||||||
|
<input
|
||||||
|
v-model.number="chaosConfig.Recipient.ErrorCode"
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
min="400"
|
||||||
|
max="599"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">
|
||||||
|
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="chaosConfig.Recipient.Probability"
|
||||||
|
type="range"
|
||||||
|
class="form-range mt-1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label>Trigger: <code>Authentication</code></label>
|
||||||
|
<div class="form-text">
|
||||||
|
Trigger an authentication error response. Note that SMTP authentication must
|
||||||
|
be configured too.
|
||||||
|
</div>
|
||||||
|
<div class="row mt-1">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label"> Response code </label>
|
||||||
|
<input
|
||||||
|
v-model.number="chaosConfig.Authentication.ErrorCode"
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
min="400"
|
||||||
|
max="599"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">
|
||||||
|
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="chaosConfig.Authentication.Probability"
|
||||||
|
type="range"
|
||||||
|
class="form-range mt-1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chaosUpdated" class="mb-3 text-center">
|
||||||
|
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
mixins: [CommonMixins],
|
||||||
@@ -9,74 +9,83 @@ export default {
|
|||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
editableTags: [],
|
editableTags: [],
|
||||||
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/),
|
validTagRe: /^([a-zA-Z0-9\- ._]){1,}$/,
|
||||||
tagToDelete: false,
|
tagToDelete: false,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
'mailbox.tags': {
|
"mailbox.tags": {
|
||||||
handler(tags) {
|
handler(tags) {
|
||||||
this.editableTags = []
|
this.editableTags = [];
|
||||||
tags.forEach((t) => {
|
tags.forEach((t) => {
|
||||||
this.editableTags.push({ before: t, after: t })
|
this.editableTags.push({ before: t, after: t });
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
validTag(t) {
|
validTag(t) {
|
||||||
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) {
|
if (!t.after.match(/^([a-zA-Z0-9\- _.]){1,}$/)) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lower = t.after.toLowerCase()
|
const lower = t.after.toLowerCase();
|
||||||
for (let x = 0; x < this.editableTags.length; x++) {
|
for (let x = 0; x < this.editableTags.length; x++) {
|
||||||
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) {
|
if (this.editableTags[x].before !== t.before && lower === this.editableTags[x].before.toLowerCase()) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
renameTag(t) {
|
renameTag(t) {
|
||||||
if (!this.validTag(t) || t.before == t.after) {
|
if (!this.validTag(t) || t.before === t.after) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
|
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
|
||||||
// the API triggers a reload via websockets
|
// the API triggers a reload via websockets
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTag() {
|
deleteTag() {
|
||||||
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
|
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
|
||||||
// the API triggers a reload via websockets
|
// the API triggers a reload via websockets
|
||||||
this.tagToDelete = false
|
this.tagToDelete = false;
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
resetTagEdit(t) {
|
resetTagEdit(t) {
|
||||||
for (let x = 0; x < this.editableTags.length; x++) {
|
for (let x = 0; x < this.editableTags.length; x++) {
|
||||||
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) {
|
if (
|
||||||
this.editableTags[x].after = this.editableTags[x].before
|
this.editableTags[x].before !== t.before &&
|
||||||
|
this.editableTags[x].before !== this.editableTags[x].after
|
||||||
|
) {
|
||||||
|
this.editableTags[x].after = this.editableTags[x].before;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true"
|
<div
|
||||||
data-bs-keyboard="false">
|
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-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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
|
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.
|
itself, and not any messages which had the tag.
|
||||||
</p>
|
</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">
|
<div class="input-group has-validation">
|
||||||
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''"
|
<input
|
||||||
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required
|
v-model.trim="t.after"
|
||||||
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before"
|
type="text"
|
||||||
@focus="resetTagEdit(t)">
|
class="form-control"
|
||||||
<button v-if="t.before != t.after" class="btn btn-success"
|
:class="!validTag(t) ? 'is-invalid' : ''"
|
||||||
@click="renameTag(t)">Save</button>
|
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>
|
<template v-else>
|
||||||
<button class="btn btn-outline-danger"
|
<button
|
||||||
|
class="btn btn-outline-danger"
|
||||||
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
|
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
|
||||||
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false">
|
@click="!tagToDelete ? (tagToDelete = t) : deleteTag()"
|
||||||
<template v-if="tagToDelete == t">
|
@blur="tagToDelete = false"
|
||||||
Confirm?
|
>
|
||||||
</template>
|
<template v-if="tagToDelete == t"> Confirm? </template>
|
||||||
<template v-else>
|
<template v-else> Delete </template>
|
||||||
Delete
|
|
||||||
</template>
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">Invalid tag name</div>
|
||||||
Invalid tag name
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { mailbox } from '../stores/mailbox.js'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
favicon: false,
|
|
||||||
iconPath: false,
|
|
||||||
iconTextColor: '#ffffff',
|
|
||||||
iconBgColor: '#dd0000',
|
|
||||||
iconFontSize: 40,
|
|
||||||
iconProcessing: false,
|
|
||||||
iconTimeout: 500,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.favicon = document.head.querySelector('link[rel="icon"]')
|
|
||||||
if (this.favicon) {
|
|
||||||
this.iconPath = this.favicon.href
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
count() {
|
|
||||||
let i = mailbox.unread
|
|
||||||
if (i > 1000) {
|
|
||||||
i = Math.floor(i / 1000) + 'k'
|
|
||||||
}
|
|
||||||
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
count() {
|
|
||||||
if (!this.favicon || this.iconProcessing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.iconProcessing = true
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.icoUpdate()
|
|
||||||
}, this.iconTimeout)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async icoUpdate() {
|
|
||||||
if (!this.favicon) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.count) {
|
|
||||||
this.iconProcessing = false
|
|
||||||
this.favicon.href = this.iconPath
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let fontSize = this.iconFontSize
|
|
||||||
// Draw badge text
|
|
||||||
let textPaddingX = 7
|
|
||||||
let textPaddingY = 3
|
|
||||||
|
|
||||||
let strlen = this.count.toString().length
|
|
||||||
|
|
||||||
if (strlen > 2) {
|
|
||||||
// if text >= 3 characters then reduce size and padding
|
|
||||||
textPaddingX = 4
|
|
||||||
fontSize = strlen > 3 ? 30 : 36
|
|
||||||
}
|
|
||||||
|
|
||||||
let canvas = document.createElement('canvas')
|
|
||||||
canvas.width = 64
|
|
||||||
canvas.height = 64
|
|
||||||
|
|
||||||
let ctx = canvas.getContext('2d')
|
|
||||||
|
|
||||||
// Draw base icon
|
|
||||||
let icon = new Image()
|
|
||||||
icon.src = this.iconPath
|
|
||||||
await icon.decode()
|
|
||||||
|
|
||||||
ctx.drawImage(icon, 0, 0, 64, 64)
|
|
||||||
|
|
||||||
// Measure text
|
|
||||||
ctx.font = `${fontSize}px Arial, sans-serif`
|
|
||||||
ctx.textAlign = 'right'
|
|
||||||
ctx.textBaseline = 'top'
|
|
||||||
let textMetrics = ctx.measureText(this.count)
|
|
||||||
|
|
||||||
// Draw badge
|
|
||||||
let paddingX = 7
|
|
||||||
let paddingY = 4
|
|
||||||
let cornerRadius = 8
|
|
||||||
|
|
||||||
let width = textMetrics.width + paddingX * 2
|
|
||||||
let height = fontSize + paddingY * 2
|
|
||||||
let x = canvas.width - width
|
|
||||||
let y = canvas.height - height - 1
|
|
||||||
|
|
||||||
ctx.fillStyle = this.iconBgColor
|
|
||||||
ctx.roundRect(x, y, width, height, cornerRadius)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.fillStyle = this.iconTextColor
|
|
||||||
ctx.fillText(
|
|
||||||
this.count,
|
|
||||||
canvas.width - textPaddingX,
|
|
||||||
canvas.height - fontSize - textPaddingY
|
|
||||||
)
|
|
||||||
|
|
||||||
this.iconProcessing = false
|
|
||||||
|
|
||||||
this.favicon.href = canvas.toDataURL("image/png")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template></template>
|
|
||||||
@@ -1,135 +1,142 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import dayjs from 'dayjs'
|
import dayjs from "dayjs";
|
||||||
import { pagination } from "../stores/pagination";
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [
|
mixins: [CommonMixins],
|
||||||
CommonMixins
|
|
||||||
],
|
|
||||||
|
|
||||||
props: {
|
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() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
pagination,
|
pagination,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
const relativeTime = require("dayjs/plugin/relativeTime");
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshUI()
|
this.refreshUI();
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
refreshUI() {
|
refreshUI() {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.$forceUpdate()
|
this.$forceUpdate();
|
||||||
this.refreshUI()
|
this.refreshUI();
|
||||||
}, 30000)
|
}, 30000);
|
||||||
},
|
},
|
||||||
|
|
||||||
getRelativeCreated(message) {
|
getRelativeCreated(message) {
|
||||||
const d = new Date(message.Created)
|
const d = new Date(message.Created);
|
||||||
return dayjs(d).fromNow()
|
return dayjs(d).fromNow();
|
||||||
},
|
},
|
||||||
|
|
||||||
getPrimaryEmailTo(message) {
|
getPrimaryEmailTo(message) {
|
||||||
for (let i in message.To) {
|
if (message.To && message.To.length > 0) {
|
||||||
return message.To[i].Address
|
return message.To[0].Address;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '[ Undisclosed recipients ]'
|
return "[ Undisclosed recipients ]";
|
||||||
},
|
},
|
||||||
|
|
||||||
isSelected(id) {
|
isSelected(id) {
|
||||||
return mailbox.selected.indexOf(id) != -1
|
return mailbox.selected.indexOf(id) !== -1;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleSelected(e, id) {
|
toggleSelected(e, id) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
|
|
||||||
if (this.isSelected(id)) {
|
if (this.isSelected(id)) {
|
||||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||||
return ele != id
|
return ele !== id;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
mailbox.selected.push(id)
|
mailbox.selected.push(id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
selectRange(e, id) {
|
selectRange(e, id) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
|
|
||||||
let selecting = false
|
let selecting = false;
|
||||||
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]
|
const lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1];
|
||||||
if (lastSelected == id) {
|
if (lastSelected === id) {
|
||||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||||
return ele != id
|
return ele !== id;
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastSelected === false) {
|
if (lastSelected === false) {
|
||||||
mailbox.selected.push(id)
|
mailbox.selected.push(id);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let d of mailbox.messages) {
|
for (const d of mailbox.messages) {
|
||||||
if (selecting) {
|
if (selecting) {
|
||||||
if (!this.isSelected(d.ID)) {
|
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
|
// 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)) {
|
if (!this.isSelected(d.ID)) {
|
||||||
mailbox.selected.push(d.ID)
|
mailbox.selected.push(d.ID);
|
||||||
}
|
}
|
||||||
selecting = true
|
selecting = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
toTagUrl(t) {
|
toTagUrl(t) {
|
||||||
if (t.match(/ /)) {
|
if (t.match(/ /)) {
|
||||||
t = `"${t}"`
|
t = `"${t}"`;
|
||||||
}
|
}
|
||||||
const p = {
|
const p = {
|
||||||
q: 'tag:' + t
|
q: "tag:" + t,
|
||||||
|
};
|
||||||
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
|
p.limit = pagination.limit.toString();
|
||||||
}
|
}
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
const params = new URLSearchParams(p);
|
||||||
p.limit = pagination.limit.toString()
|
return "/search?" + params.toString();
|
||||||
}
|
|
||||||
const params = new URLSearchParams(p)
|
|
||||||
return '/search?' + params.toString()
|
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="mailbox.messages && mailbox.messages.length">
|
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||||
<div class="list-group my-2">
|
<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"
|
: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="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' : ''"
|
:class="[message.Read ? 'read' : '', isSelected(message.ID) ? ' selected' : '']"
|
||||||
@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)"
|
@click.meta="toggleSelected($event, message.ID)"
|
||||||
@click.shift="selectRange($event, message.ID)">
|
@click.ctrl="toggleSelected($event, message.ID)"
|
||||||
|
@click.shift="selectRange($event, message.ID)"
|
||||||
|
>
|
||||||
<div class="col-lg-3">
|
<div class="col-lg-3">
|
||||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
<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) }}
|
{{ getRelativeCreated(message) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="message.From" class="overflow-x-hidden">
|
<div v-if="message.From" class="overflow-x-hidden">
|
||||||
@@ -142,30 +149,37 @@ export default {
|
|||||||
<div class="overflow-x-hidden">
|
<div class="overflow-x-hidden">
|
||||||
<div class="text-truncate text-muted small privacy">
|
<div class="text-truncate text-muted small privacy">
|
||||||
To: {{ getPrimaryEmailTo(message) }}
|
To: {{ getPrimaryEmailTo(message) }}
|
||||||
<span v-if="message.To && message.To.length > 1">
|
<span v-if="message.To && message.To.length > 1"> [+{{ message.To.length - 1 }}] </span>
|
||||||
[+{{ message.To.length - 1 }}]
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||||
<div class="subject text-truncate text-spaces-nowrap">
|
<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>
|
||||||
<div v-if="message.Snippet != ''" class="small text-muted text-truncate">
|
<div v-if="message.Snippet !== ''" class="small text-muted text-truncate">
|
||||||
{{ message.Snippet }}
|
{{ message.Snippet }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="message.Tags.length">
|
<div v-if="message.Tags.length">
|
||||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
<RouterLink
|
||||||
v-on:click="pagination.start = 0"
|
v-for="t in message.Tags"
|
||||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
:key="t"
|
||||||
:title="'Filter messages tagged with ' + 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 }}
|
{{ t }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
<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) }}
|
{{ getFileSize(message.Size) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
|
<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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-center mt-5">
|
<p class="text-center mt-5">
|
||||||
<span v-if="loadingMessages > 0" class="text-muted">
|
<span v-if="loadingMessages > 0" class="text-muted"> Loading messages... </span>
|
||||||
Loading messages...
|
<template v-else-if="getSearch()"
|
||||||
</span>
|
>No results for <code>{{ getSearch() }}</code></template
|
||||||
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
|
>
|
||||||
<template v-else>No messages in your mailbox</template>
|
<template v-else>No messages in your mailbox</template>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,156 +1,193 @@
|
|||||||
<script>
|
<script>
|
||||||
import NavSelected from '../components/NavSelected.vue'
|
import NavSelected from "../components/NavSelected.vue";
|
||||||
import AjaxLoader from "./AjaxLoader.vue"
|
import AjaxLoader from "./AjaxLoader.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
import { pagination } from '../stores/pagination'
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
NavSelected,
|
NavSelected,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
modals: {
|
modals: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ['loadMessages'],
|
emits: ["loadMessages"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
pagination,
|
pagination,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
reloadInbox() {
|
reloadInbox() {
|
||||||
const paginationParams = this.getPaginationParams()
|
const paginationParams = this.getPaginationParams();
|
||||||
const reload = paginationParams?.start ? false : true
|
const reload = !paginationParams?.start;
|
||||||
|
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
if (reload) {
|
if (reload) {
|
||||||
// already on first page, reload messages
|
// already on first page, reload messages
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loadMessages() {
|
loadMessages() {
|
||||||
this.hideNav() // hide mobile menu
|
this.hideNav(); // hide mobile menu
|
||||||
this.$emit('loadMessages')
|
this.$emit("loadMessages");
|
||||||
},
|
},
|
||||||
|
|
||||||
markAllRead() {
|
markAllRead() {
|
||||||
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
|
this.put(this.resolve(`/api/v1/messages`), { read: true }, (response) => {
|
||||||
window.scrollInPlace = true
|
window.scrollInPlace = true;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteAllMessages() {
|
deleteAllMessages() {
|
||||||
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
|
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
|
||||||
pagination.start = 0
|
pagination.start = 0;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
})
|
});
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="!modals">
|
<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">
|
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||||
{{ mailbox.uiConfig.Label }}
|
{{ mailbox.uiConfig.Label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||||
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
|
<button class="list-group-item list-group-item-action active" @click="reloadInbox">
|
||||||
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
|
<i v-if="mailbox.connected" class="bi bi-envelope-fill me-1"></i>
|
||||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
<i v-else class="bi bi-arrow-clockwise me-1"></i>
|
||||||
<span class="ms-1">Inbox</span>
|
<span class="ms-1">Inbox</span>
|
||||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
<span
|
||||||
v-if="mailbox.unread">
|
v-if="mailbox.unread"
|
||||||
|
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||||
|
title="Unread messages"
|
||||||
|
>
|
||||||
{{ formatNumber(mailbox.unread) }}
|
{{ formatNumber(mailbox.unread) }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<template v-if="!mailbox.selected.length">
|
<template v-if="!mailbox.selected.length">
|
||||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
<button
|
||||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
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>
|
<i class="bi bi-eye-fill me-1"></i>
|
||||||
Mark all read
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
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>
|
<i class="bi bi-eye-fill me-1"></i>
|
||||||
Mark all read
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
||||||
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
||||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
<button
|
||||||
:disabled="!mailbox.total" @click="deleteAllMessages">
|
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>
|
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||||
Delete all
|
Delete all
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
|
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>
|
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||||
Delete all
|
Delete all
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<NavSelected @loadMessages="loadMessages" />
|
<NavSelected @load-messages="loadMessages" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
<div
|
||||||
aria-hidden="true">
|
id="MarkAllReadModal"
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="MarkAllReadModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
This will mark {{ formatNumber(mailbox.unread) }}
|
This will mark {{ formatNumber(mailbox.unread) }} message<span v-if="mailbox.unread > 1"
|
||||||
message<span v-if="mailbox.unread > 1">s</span> as read.
|
>s</span
|
||||||
|
>
|
||||||
|
as read.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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"
|
<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
|
||||||
v-on:click="markAllRead">Confirm</button>
|
Confirm
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
<div
|
||||||
aria-hidden="true">
|
id="DeleteAllModal"
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="DeleteAllModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
This will permanently delete {{ formatNumber(mailbox.total) }}
|
This will permanently delete {{ formatNumber(mailbox.total) }} message<span
|
||||||
message<span v-if="mailbox.total > 1">s</span>.
|
v-if="mailbox.total > 1"
|
||||||
|
>s</span
|
||||||
|
>.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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"
|
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||||
v-on:click="deleteAllMessages">Delete</button>
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
120
server/ui-src/components/NavPagination.vue
Normal file
120
server/ui-src/components/NavPagination.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
|
import { mailbox } from "../stores/mailbox";
|
||||||
|
import { limitOptions, pagination } from "../stores/pagination";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
total: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pagination,
|
||||||
|
mailbox,
|
||||||
|
limitOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
canPrev() {
|
||||||
|
return pagination.start > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
canNext() {
|
||||||
|
return this.total > pagination.start + mailbox.messages.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
// returns the number of next X messages
|
||||||
|
nextMessages() {
|
||||||
|
let t = pagination.start + parseInt(pagination.limit, 10);
|
||||||
|
if (t > this.total) {
|
||||||
|
t = this.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
changeLimit() {
|
||||||
|
pagination.start = 0;
|
||||||
|
this.updateQueryParams();
|
||||||
|
},
|
||||||
|
|
||||||
|
viewNext() {
|
||||||
|
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10);
|
||||||
|
this.updateQueryParams();
|
||||||
|
},
|
||||||
|
|
||||||
|
viewPrev() {
|
||||||
|
let s = pagination.start - pagination.limit;
|
||||||
|
if (s < 0) {
|
||||||
|
s = 0;
|
||||||
|
}
|
||||||
|
pagination.start = s;
|
||||||
|
this.updateQueryParams();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateQueryParams() {
|
||||||
|
const path = this.$route.path;
|
||||||
|
const p = {
|
||||||
|
...this.$route.query,
|
||||||
|
};
|
||||||
|
if (pagination.start > 0) {
|
||||||
|
p.start = pagination.start.toString();
|
||||||
|
} else {
|
||||||
|
delete p.start;
|
||||||
|
}
|
||||||
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
|
p.limit = pagination.limit.toString();
|
||||||
|
} else {
|
||||||
|
delete p.limit;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(p);
|
||||||
|
this.$router.push(path + "?" + params.toString());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<select
|
||||||
|
v-model="pagination.limit"
|
||||||
|
class="form-select form-select-sm d-inline w-auto me-2"
|
||||||
|
:disabled="total == 0"
|
||||||
|
@change="changeLimit"
|
||||||
|
>
|
||||||
|
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<small>
|
||||||
|
<template v-if="total > 0">
|
||||||
|
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||||
|
<small>of</small>
|
||||||
|
{{ formatNumber(total) }}
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-muted">0 of 0</span>
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-light ms-2 me-1"
|
||||||
|
:disabled="!canPrev"
|
||||||
|
:title="'View previous ' + pagination.limit + ' messages'"
|
||||||
|
@click="viewPrev"
|
||||||
|
>
|
||||||
|
<i class="bi bi-caret-left-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-light"
|
||||||
|
:disabled="!canNext"
|
||||||
|
:title="'View next ' + pagination.limit + ' messages'"
|
||||||
|
@click="viewNext"
|
||||||
|
>
|
||||||
|
<i class="bi bi-caret-right-fill"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -1,79 +1,79 @@
|
|||||||
<script>
|
<script>
|
||||||
import NavSelected from '../components/NavSelected.vue'
|
import NavSelected from "../components/NavSelected.vue";
|
||||||
import AjaxLoader from './AjaxLoader.vue'
|
import AjaxLoader from "./AjaxLoader.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
import { pagination } from '../stores/pagination'
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
NavSelected,
|
NavSelected,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
modals: {
|
modals: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ['loadMessages'],
|
emits: ["loadMessages"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
pagination,
|
pagination,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
loadMessages() {
|
loadMessages() {
|
||||||
this.hideNav() // hide mobile menu
|
this.hideNav(); // hide mobile menu
|
||||||
this.$emit('loadMessages')
|
this.$emit("loadMessages");
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteAllMessages() {
|
deleteAllMessages() {
|
||||||
const s = this.getSearch()
|
const s = this.getSearch();
|
||||||
if (!s) {
|
if (!s) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
let uri = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
|
||||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||||
uri += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
uri += "&tz=" + encodeURIComponent(mailbox.timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.delete(uri, false, () => {
|
this.delete(uri, false, () => {
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
markAllRead() {
|
markAllRead() {
|
||||||
const s = this.getSearch()
|
const s = this.getSearch();
|
||||||
if (!s) {
|
if (!s) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let uri = this.resolve(`/api/v1/messages`)
|
let uri = this.resolve(`/api/v1/messages`);
|
||||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||||
uri += '?tz=' + encodeURIComponent(mailbox.timeZone)
|
uri += "?tz=" + encodeURIComponent(mailbox.timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.put(uri, { 'read': true, "search": s }, () => {
|
this.put(uri, { read: true, search: s }, () => {
|
||||||
window.scrollInPlace = true
|
window.scrollInPlace = true;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="!modals">
|
<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">
|
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||||
{{ mailbox.uiConfig.Label }}
|
{{ mailbox.uiConfig.Label }}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,83 +83,121 @@ export default {
|
|||||||
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
|
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
|
||||||
<i class="bi bi-arrow-return-left me-1"></i>
|
<i class="bi bi-arrow-return-left me-1"></i>
|
||||||
<span class="ms-1">Inbox</span>
|
<span class="ms-1">Inbox</span>
|
||||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
<span
|
||||||
v-if="mailbox.unread">
|
v-if="mailbox.unread"
|
||||||
|
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||||
|
title="Unread messages"
|
||||||
|
>
|
||||||
{{ formatNumber(mailbox.unread) }}
|
{{ formatNumber(mailbox.unread) }}
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<template v-if="!mailbox.selected.length">
|
<template v-if="!mailbox.selected.length">
|
||||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
<button
|
||||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
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>
|
<i class="bi bi-eye-fill me-1"></i>
|
||||||
Mark all read
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
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>
|
<i class="bi bi-eye-fill me-1"></i>
|
||||||
Mark all read
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
||||||
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
||||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
<button
|
||||||
@click="deleteAllMessages" :disabled="!mailbox.count">
|
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>
|
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||||
Delete all
|
Delete all
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
|
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>
|
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||||
Delete all
|
Delete all
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<NavSelected @loadMessages="loadMessages" />
|
<NavSelected @load-messages="loadMessages" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
<div
|
||||||
aria-hidden="true">
|
id="MarkAllReadModal"
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="MarkAllReadModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
This will mark {{ formatNumber(mailbox.messages_unread) }}
|
This will mark {{ formatNumber(mailbox.messages_unread) }} message<span
|
||||||
message<span v-if="mailbox.messages_unread > 1">s</span>
|
v-if="mailbox.messages_unread > 1"
|
||||||
|
>s</span
|
||||||
|
>
|
||||||
matching <code>{{ getSearch() }}</code>
|
matching <code>{{ getSearch() }}</code>
|
||||||
as read.
|
as read.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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"
|
<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
|
||||||
v-on:click="markAllRead">Confirm</button>
|
Confirm
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
<div
|
||||||
aria-hidden="true">
|
id="DeleteAllModal"
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="DeleteAllModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
This will permanently delete {{ formatNumber(mailbox.count) }}
|
This will permanently delete {{ formatNumber(mailbox.count) }} message<span
|
||||||
message<span v-if="mailbox.count > 1">s</span> matching
|
v-if="mailbox.count > 1"
|
||||||
|
>s</span
|
||||||
|
>
|
||||||
|
matching
|
||||||
<code>{{ getSearch() }}</code>
|
<code>{{ getSearch() }}</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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"
|
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||||
v-on:click="deleteAllMessages">Delete</button>
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,118 +1,124 @@
|
|||||||
<script>
|
<script>
|
||||||
import AjaxLoader from './AjaxLoader.vue'
|
import AjaxLoader from "./AjaxLoader.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
components: {
|
||||||
|
AjaxLoader,
|
||||||
|
},
|
||||||
|
|
||||||
components: {
|
mixins: [CommonMixins],
|
||||||
AjaxLoader,
|
|
||||||
},
|
|
||||||
|
|
||||||
emits: ['loadMessages'],
|
emits: ["loadMessages"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
loadMessages() {
|
loadMessages() {
|
||||||
this.$emit('loadMessages')
|
this.$emit("loadMessages");
|
||||||
},
|
},
|
||||||
|
|
||||||
// mark selected messages as read
|
// mark selected messages as read
|
||||||
markSelectedRead() {
|
markSelectedRead() {
|
||||||
if (!mailbox.selected.length) {
|
if (!mailbox.selected.length) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => {
|
this.put(this.resolve(`/api/v1/messages`), { Read: true, IDs: mailbox.selected }, (response) => {
|
||||||
window.scrollInPlace = true
|
window.scrollInPlace = true;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
isSelected(id) {
|
isSelected(id) {
|
||||||
return mailbox.selected.indexOf(id) != -1
|
return mailbox.selected.indexOf(id) !== -1;
|
||||||
},
|
},
|
||||||
|
|
||||||
// mark selected messages as unread
|
// mark selected messages as unread
|
||||||
markSelectedUnread() {
|
markSelectedUnread() {
|
||||||
if (!mailbox.selected.length) {
|
if (!mailbox.selected.length) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => {
|
this.put(this.resolve(`/api/v1/messages`), { Read: false, IDs: mailbox.selected }, (response) => {
|
||||||
window.scrollInPlace = true
|
window.scrollInPlace = true;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// universal handler to delete current or selected messages
|
// universal handler to delete current or selected messages
|
||||||
deleteMessages() {
|
deleteMessages() {
|
||||||
let ids = []
|
let ids = [];
|
||||||
ids = JSON.parse(JSON.stringify(mailbox.selected))
|
ids = JSON.parse(JSON.stringify(mailbox.selected));
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => {
|
this.delete(this.resolve(`/api/v1/messages`), { IDs: ids }, (response) => {
|
||||||
window.scrollInPlace = true
|
window.scrollInPlace = true;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// test if any selected emails are unread
|
// test if any selected emails are unread
|
||||||
selectedHasUnread() {
|
selectedHasUnread() {
|
||||||
if (!mailbox.selected.length) {
|
if (!mailbox.selected.length) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
for (let i in mailbox.messages) {
|
for (const i in mailbox.messages) {
|
||||||
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// test of any selected emails are read
|
// test of any selected emails are read
|
||||||
selectedHasRead() {
|
selectedHasRead() {
|
||||||
if (!mailbox.selected.length) {
|
if (!mailbox.selected.length) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
for (let i in mailbox.messages) {
|
for (const i in mailbox.messages) {
|
||||||
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="mailbox.selected.length">
|
<template v-if="mailbox.selected.length">
|
||||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
|
<button
|
||||||
v-on:click="markSelectedRead">
|
class="list-group-item list-group-item-action"
|
||||||
<i class="bi bi-eye-fill me-1"></i>
|
:disabled="!selectedHasUnread()"
|
||||||
Mark read
|
@click="markSelectedRead"
|
||||||
</button>
|
>
|
||||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
|
<i class="bi bi-eye-fill me-1"></i>
|
||||||
v-on:click="markSelectedUnread">
|
Mark read
|
||||||
<i class="bi bi-eye-slash me-1"></i>
|
</button>
|
||||||
Mark unread
|
<button
|
||||||
</button>
|
class="list-group-item list-group-item-action"
|
||||||
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages()">
|
:disabled="!selectedHasRead()"
|
||||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
@click="markSelectedUnread"
|
||||||
Delete selected
|
>
|
||||||
</button>
|
<i class="bi bi-eye-slash me-1"></i>
|
||||||
<button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []">
|
Mark unread
|
||||||
<i class="bi bi-x-circle me-1"></i>
|
</button>
|
||||||
Cancel selection
|
<button class="list-group-item list-group-item-action" @click="deleteMessages()">
|
||||||
</button>
|
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||||
</template>
|
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>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
import { pagination } from '../stores/pagination'
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
mixins: [CommonMixins],
|
||||||
@@ -10,79 +10,77 @@ export default {
|
|||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
pagination,
|
pagination,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
// test whether a tag is currently being searched for (in the URL)
|
// test whether a tag is currently being searched for (in the URL)
|
||||||
inSearch(tag) {
|
inSearch(tag) {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const query = urlParams.get('q')
|
const query = urlParams.get("q");
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i')
|
const re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, "i");
|
||||||
return query.match(re)
|
return query.match(re);
|
||||||
},
|
},
|
||||||
|
|
||||||
// toggle a tag search in the search URL, add or remove it accordingly
|
// toggle a tag search in the search URL, add or remove it accordingly
|
||||||
toggleTag(e, tag) {
|
toggleTag(e, tag) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
let query = urlParams.get('q') ? urlParams.get('q') : ''
|
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)) {
|
if (query.match(re)) {
|
||||||
// remove is exists
|
// remove is exists
|
||||||
query = query.replace(re, '$1$4')
|
query = query.replace(re, "$1$4");
|
||||||
} else {
|
} else {
|
||||||
// add to query
|
// add to query
|
||||||
if (tag.match(/ /)) {
|
if (tag.match(/ /)) {
|
||||||
tag = `"${tag}"`
|
tag = `"${tag}"`;
|
||||||
}
|
}
|
||||||
query = query + " tag:" + tag
|
query = query + " tag:" + tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
query = query.trim()
|
query = query.trim();
|
||||||
|
|
||||||
if (query == '') {
|
if (query === "") {
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
q: query,
|
q: query,
|
||||||
start: pagination.start.toString(),
|
start: pagination.start.toString(),
|
||||||
limit: pagination.limit.toString(),
|
limit: pagination.limit.toString(),
|
||||||
})
|
});
|
||||||
this.$router.push('/search?' + params.toString())
|
this.$router.push("/search?" + params.toString());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
toTagUrl(t) {
|
toTagUrl(t) {
|
||||||
if (t.match(/ /)) {
|
if (t.match(/ /)) {
|
||||||
t = `"${t}"`
|
t = `"${t}"`;
|
||||||
}
|
}
|
||||||
const p = {
|
const p = {
|
||||||
q: 'tag:' + t
|
q: "tag:" + t,
|
||||||
|
};
|
||||||
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
|
p.limit = pagination.limit.toString();
|
||||||
}
|
}
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
const params = new URLSearchParams(p);
|
||||||
p.limit = pagination.limit.toString()
|
return "/search?" + params.toString();
|
||||||
}
|
|
||||||
const params = new URLSearchParams(p)
|
|
||||||
return '/search?' + params.toString()
|
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||||
<div class="mt-4 text-muted">
|
<div class="mt-4 text-muted">
|
||||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Tags</button>
|
||||||
Tags
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
|
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
|
||||||
@@ -99,12 +97,20 @@ export default {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group mt-1 mb-2">
|
<div class="list-group mt-1 mb-2">
|
||||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav"
|
<RouterLink
|
||||||
@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)"
|
v-for="tag in mailbox.tags"
|
||||||
|
:key="tag"
|
||||||
|
:to="toTagUrl(tag)"
|
||||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||||
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
class="list-group-item list-group-item-action small px-2"
|
||||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
:class="inSearch(tag) ? 'active' : ''"
|
||||||
<i class="bi bi-tag" v-else></i>
|
@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 }}
|
{{ tag }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
<script>
|
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
|
||||||
import { Toast } from 'bootstrap'
|
|
||||||
import { mailbox } from '../stores/mailbox'
|
|
||||||
import { pagination } from '../stores/pagination'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
// global event bus to handle message status changes
|
|
||||||
inject: ["eventBus"],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
pagination,
|
|
||||||
mailbox,
|
|
||||||
toastMessage: false,
|
|
||||||
reconnectRefresh: false,
|
|
||||||
socketURI: false,
|
|
||||||
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
|
||||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
|
||||||
pauseNotifications: false, // prevent spamming
|
|
||||||
version: false,
|
|
||||||
clientErrors: [], // errors received via websocket
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
const d = document.getElementById('app')
|
|
||||||
if (d) {
|
|
||||||
this.version = d.dataset.version
|
|
||||||
}
|
|
||||||
|
|
||||||
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
|
|
||||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
|
|
||||||
|
|
||||||
this.socketBreakReset()
|
|
||||||
this.connect()
|
|
||||||
|
|
||||||
mailbox.notificationsSupported = window.isSecureContext
|
|
||||||
&& ("Notification" in window && Notification.permission !== "denied")
|
|
||||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
|
||||||
|
|
||||||
this.errorNotificationCron()
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
// websocket connect
|
|
||||||
connect() {
|
|
||||||
const ws = new WebSocket(this.socketURI)
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = JSON.parse(e.data)
|
|
||||||
} catch (e) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// new messages
|
|
||||||
if (response.Type == "new" && response.Data) {
|
|
||||||
this.eventBus.emit("new", response.Data)
|
|
||||||
|
|
||||||
for (let i in response.Data.Tags) {
|
|
||||||
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
|
|
||||||
mailbox.tags.push(response.Data.Tags[i])
|
|
||||||
mailbox.tags.sort((a, b) => {
|
|
||||||
return a.toLowerCase().localeCompare(b.toLowerCase())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// send notifications
|
|
||||||
if (!this.pauseNotifications) {
|
|
||||||
this.pauseNotifications = true
|
|
||||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
|
|
||||||
this.browserNotify("New mail from: " + from, response.Data.Subject)
|
|
||||||
this.setMessageToast(response.Data)
|
|
||||||
// delay notifications by 2s
|
|
||||||
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
|
|
||||||
}
|
|
||||||
} else if (response.Type == "prune") {
|
|
||||||
// messages have been deleted, reload messages to adjust
|
|
||||||
window.scrollInPlace = true
|
|
||||||
mailbox.refresh = true // trigger refresh
|
|
||||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
|
||||||
this.eventBus.emit("prune");
|
|
||||||
} else if (response.Type == "stats" && response.Data) {
|
|
||||||
// refresh mailbox stats
|
|
||||||
mailbox.total = response.Data.Total
|
|
||||||
mailbox.unread = response.Data.Unread
|
|
||||||
|
|
||||||
// detect version updated, refresh is needed
|
|
||||||
if (this.version != response.Data.Version) {
|
|
||||||
location.reload()
|
|
||||||
}
|
|
||||||
} else if (response.Type == "delete" && response.Data) {
|
|
||||||
// broadcast for components
|
|
||||||
this.eventBus.emit("delete", response.Data)
|
|
||||||
} else if (response.Type == "update" && response.Data) {
|
|
||||||
// broadcast for components
|
|
||||||
this.eventBus.emit("update", response.Data)
|
|
||||||
} else if (response.Type == "truncate") {
|
|
||||||
// broadcast for components
|
|
||||||
this.eventBus.emit("truncate")
|
|
||||||
} else if (response.Type == "error") {
|
|
||||||
// broadcast for components
|
|
||||||
this.addClientError(response.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
mailbox.connected = true
|
|
||||||
this.socketLastConnection = Date.now()
|
|
||||||
if (this.reconnectRefresh) {
|
|
||||||
this.reconnectRefresh = false
|
|
||||||
mailbox.refresh = true // trigger refresh
|
|
||||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = (e) => {
|
|
||||||
if (this.socketLastConnection == 0) {
|
|
||||||
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
|
||||||
console.log('Unable to connect to websocket, disabling websocket support')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mailbox.connected) {
|
|
||||||
// count disconnections
|
|
||||||
this.socketBreaks++
|
|
||||||
}
|
|
||||||
|
|
||||||
// set disconnected state
|
|
||||||
mailbox.connected = false
|
|
||||||
|
|
||||||
if (this.socketBreaks > 3) {
|
|
||||||
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
|
||||||
// something is not working right on their end, see issue #319
|
|
||||||
console.log('Unstable websocket connection, disabling websocket support')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Date.now() - this.socketLastConnection > 5000) {
|
|
||||||
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
|
||||||
this.reconnectRefresh = true
|
|
||||||
} else {
|
|
||||||
this.reconnectRefresh = false
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.connect() // reconnect
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onerror = function (err) {
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
socketBreakReset() {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.socketBreaks = 0
|
|
||||||
this.socketBreakReset()
|
|
||||||
}, 15000)
|
|
||||||
},
|
|
||||||
|
|
||||||
browserNotify(title, message) {
|
|
||||||
if (!("Notification" in window)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Notification.permission === "granted") {
|
|
||||||
let options = {
|
|
||||||
body: message,
|
|
||||||
icon: this.resolve('/notification.png')
|
|
||||||
}
|
|
||||||
new Notification(title, options)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setMessageToast(m) {
|
|
||||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
|
||||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.toastMessage = m
|
|
||||||
|
|
||||||
const el = document.getElementById('messageToast')
|
|
||||||
if (el) {
|
|
||||||
el.addEventListener('hidden.bs.toast', () => {
|
|
||||||
this.toastMessage = false
|
|
||||||
})
|
|
||||||
|
|
||||||
Toast.getOrCreateInstance(el).show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
closeToast() {
|
|
||||||
const el = document.getElementById('messageToast')
|
|
||||||
if (el) {
|
|
||||||
Toast.getOrCreateInstance(el).hide()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addClientError(d) {
|
|
||||||
d.expire = Date.now() + 5000 // expire after 5s
|
|
||||||
this.clientErrors.push(d)
|
|
||||||
},
|
|
||||||
|
|
||||||
errorNotificationCron() {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.clientErrors.forEach((err, idx) => {
|
|
||||||
if (err.expire < Date.now()) {
|
|
||||||
this.clientErrors.splice(idx, 1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.errorNotificationCron()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
|
||||||
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
|
||||||
<div class="toast-header">
|
|
||||||
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
|
|
||||||
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
|
||||||
</svg>
|
|
||||||
<strong class="me-auto">{{ error.Type }}</strong>
|
|
||||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">
|
|
||||||
{{ error.Message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
|
||||||
<div class="toast-header" v-if="toastMessage">
|
|
||||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
|
||||||
<strong class="me-auto">
|
|
||||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
|
||||||
</strong>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toast-body">
|
|
||||||
<div>
|
|
||||||
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
|
|
||||||
@click="closeToast">
|
|
||||||
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
|
|
||||||
<template v-else>
|
|
||||||
[ no subject ]
|
|
||||||
</template>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script>
|
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
|
||||||
import { mailbox } from '../stores/mailbox'
|
|
||||||
import { limitOptions, pagination } from '../stores/pagination'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
|
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
props: {
|
|
||||||
total: Number,
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
pagination,
|
|
||||||
mailbox,
|
|
||||||
limitOptions,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
canPrev() {
|
|
||||||
return pagination.start > 0
|
|
||||||
},
|
|
||||||
|
|
||||||
canNext() {
|
|
||||||
return this.total > (pagination.start + mailbox.messages.length)
|
|
||||||
},
|
|
||||||
|
|
||||||
// returns the number of next X messages
|
|
||||||
nextMessages() {
|
|
||||||
let t = pagination.start + parseInt(pagination.limit, 10)
|
|
||||||
if (t > this.total) {
|
|
||||||
t = this.total
|
|
||||||
}
|
|
||||||
|
|
||||||
return t
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
changeLimit() {
|
|
||||||
pagination.start = 0
|
|
||||||
this.updateQueryParams()
|
|
||||||
},
|
|
||||||
|
|
||||||
viewNext() {
|
|
||||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
|
|
||||||
this.updateQueryParams()
|
|
||||||
},
|
|
||||||
|
|
||||||
viewPrev() {
|
|
||||||
let s = pagination.start - pagination.limit
|
|
||||||
if (s < 0) {
|
|
||||||
s = 0
|
|
||||||
}
|
|
||||||
pagination.start = s
|
|
||||||
this.updateQueryParams()
|
|
||||||
},
|
|
||||||
|
|
||||||
updateQueryParams() {
|
|
||||||
const path = this.$route.path
|
|
||||||
const p = {
|
|
||||||
...this.$route.query
|
|
||||||
}
|
|
||||||
if (pagination.start > 0) {
|
|
||||||
p.start = pagination.start.toString()
|
|
||||||
} else {
|
|
||||||
delete p.start
|
|
||||||
}
|
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
|
||||||
p.limit = pagination.limit.toString()
|
|
||||||
} else {
|
|
||||||
delete p.limit
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams(p)
|
|
||||||
this.$router.push(path + '?' + params.toString())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
|
|
||||||
:disabled="total == 0">
|
|
||||||
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<small>
|
|
||||||
<template v-if="total > 0">
|
|
||||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
|
||||||
<small>of</small>
|
|
||||||
{{ formatNumber(total) }}
|
|
||||||
</template>
|
|
||||||
<span v-else class="text-muted">0 of 0</span>
|
|
||||||
</small>
|
|
||||||
|
|
||||||
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
|
||||||
:title="'View previous ' + pagination.limit + ' messages'">
|
|
||||||
<i class="bi bi-caret-left-fill"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
|
|
||||||
:title="'View next ' + pagination.limit + ' messages'">
|
|
||||||
<i class="bi bi-caret-right-fill"></i>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -1,78 +1,84 @@
|
|||||||
<script>
|
<script>
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import { pagination } from '../stores/pagination'
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
emits: ['loadMessages'],
|
emits: ["loadMessages"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
search: ''
|
search: "",
|
||||||
}
|
};
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.searchFromURL()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
$route() {
|
$route() {
|
||||||
this.searchFromURL()
|
this.searchFromURL();
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.searchFromURL();
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
searchFromURL() {
|
searchFromURL() {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
this.search = urlParams.get('q') ? urlParams.get('q') : ''
|
this.search = urlParams.get("q") ? urlParams.get("q") : "";
|
||||||
},
|
},
|
||||||
|
|
||||||
doSearch(e) {
|
doSearch(e) {
|
||||||
pagination.start = 0
|
pagination.start = 0;
|
||||||
if (this.search == '') {
|
if (this.search === "") {
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
} else {
|
} else {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const curr = urlParams.get('q')
|
const curr = urlParams.get("q");
|
||||||
if (curr && curr == this.search) {
|
if (curr && curr === this.search) {
|
||||||
pagination.start = 0
|
pagination.start = 0;
|
||||||
this.$emit('loadMessages')
|
this.$emit("loadMessages");
|
||||||
}
|
}
|
||||||
const p = {
|
const p = {
|
||||||
q: this.search
|
q: this.search,
|
||||||
}
|
};
|
||||||
if (pagination.start > 0) {
|
if (pagination.start > 0) {
|
||||||
p.start = pagination.start.toString()
|
p.start = pagination.start.toString();
|
||||||
}
|
}
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
p.limit = pagination.limit.toString()
|
p.limit = pagination.limit.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(p)
|
const params = new URLSearchParams(p);
|
||||||
this.$router.push('/search?' + params.toString())
|
this.$router.push("/search?" + params.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
|
|
||||||
resetSearch() {
|
resetSearch() {
|
||||||
this.search = ''
|
this.search = "";
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form v-on:submit="doSearch">
|
<form @submit="doSearch">
|
||||||
<div class="input-group flex-nowrap">
|
<div class="input-group flex-nowrap">
|
||||||
<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
|
<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"
|
<input
|
||||||
placeholder="Search mailbox">
|
v-model.trim="search"
|
||||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''"
|
type="text"
|
||||||
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
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>
|
</div>
|
||||||
<button class="btn btn-outline-secondary" type="submit">
|
<button class="btn btn-outline-secondary" type="submit">
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search"></i>
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
<script>
|
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
|
||||||
import Tags from 'bootstrap5-tags'
|
|
||||||
import timezones from 'timezones-list'
|
|
||||||
import { mailbox } from '../stores/mailbox'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
mailbox,
|
|
||||||
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
|
|
||||||
timezones,
|
|
||||||
chaosConfig: false,
|
|
||||||
chaosUpdated: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
theme(v) {
|
|
||||||
if (v == 'auto') {
|
|
||||||
localStorage.removeItem('theme')
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('theme', v)
|
|
||||||
}
|
|
||||||
this.setTheme()
|
|
||||||
},
|
|
||||||
|
|
||||||
chaosConfig: {
|
|
||||||
handler() {
|
|
||||||
this.chaosUpdated = true
|
|
||||||
},
|
|
||||||
deep: true
|
|
||||||
},
|
|
||||||
|
|
||||||
'mailbox.skipConfirmations'(v) {
|
|
||||||
if (v) {
|
|
||||||
localStorage.setItem('skip-confirmations', 'true')
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('skip-confirmations')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.setTheme()
|
|
||||||
this.$nextTick(function () {
|
|
||||||
Tags.init('select.tz')
|
|
||||||
})
|
|
||||||
|
|
||||||
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
setTheme() {
|
|
||||||
if (
|
|
||||||
this.theme === 'auto' &&
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
) {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', this.theme)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
loadChaos() {
|
|
||||||
this.get(this.resolve('/api/v1/chaos'), null, (response) => {
|
|
||||||
this.chaosConfig = response.data
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.chaosUpdated = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
saveChaos() {
|
|
||||||
this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => {
|
|
||||||
this.chaosConfig = response.data
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.chaosUpdated = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true"
|
|
||||||
data-bs-keyboard="false">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit settings</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link active" id="ui-tab" data-bs-toggle="tab"
|
|
||||||
data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane"
|
|
||||||
aria-selected="true">Web UI</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="chaos-tab" data-bs-toggle="tab"
|
|
||||||
data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane"
|
|
||||||
aria-selected="false" @click="loadChaos">Chaos</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="tab-content">
|
|
||||||
<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab"
|
|
||||||
tabindex="0">
|
|
||||||
<div class="my-3">
|
|
||||||
<label for="theme" class="form-label">Mailpit theme</label>
|
|
||||||
<select class="form-select" v-model="theme" id="theme">
|
|
||||||
<option value="auto">Auto (detect from browser)</option>
|
|
||||||
<option value="light">Light theme</option>
|
|
||||||
<option value="dark">Dark theme</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
|
||||||
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone"
|
|
||||||
data-allow-same="true">
|
|
||||||
<option disabled hidden value="">Select a timezone...</option>
|
|
||||||
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
|
|
||||||
v-model="mailbox.showTagColors">
|
|
||||||
<label class="form-check-label" for="tagColors">
|
|
||||||
Use auto-generated tag colors
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
|
|
||||||
v-model="mailbox.showHTMLCheck">
|
|
||||||
<label class="form-check-label" for="htmlCheck">
|
|
||||||
Show HTML check message tab
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
|
|
||||||
v-model="mailbox.showLinkCheck">
|
|
||||||
<label class="form-check-label" for="linkCheck">
|
|
||||||
Show link check message tab
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
|
|
||||||
v-model="mailbox.showSpamCheck">
|
|
||||||
<label class="form-check-label" for="spamCheck">
|
|
||||||
Show spam check message tab
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch"
|
|
||||||
id="skip-confirmations" v-model="mailbox.skipConfirmations">
|
|
||||||
<label class="form-check-label" for="skip-confirmations">
|
|
||||||
Skip
|
|
||||||
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
|
||||||
<code>Delete all</code> &
|
|
||||||
</template>
|
|
||||||
<code>Mark all read</code> confirmation dialogs
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab"
|
|
||||||
tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled">
|
|
||||||
<p class="my-3">
|
|
||||||
<b>Chaos</b> allows you to set random SMTP failures and response codes at various
|
|
||||||
stages in a SMTP transaction to test application resilience
|
|
||||||
(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank">
|
|
||||||
see documentation
|
|
||||||
</a>).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<code>Response code</code> is the SMTP error code returned by the server if this
|
|
||||||
error is triggered. Error codes must range between 400 and 599.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>Error probability</code> is the % chance that the error will occur per message
|
|
||||||
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
|
||||||
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
|
||||||
messages received.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<template v-if="chaosConfig">
|
|
||||||
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label>Trigger: <code>Sender</code></label>
|
|
||||||
<div class="form-text">
|
|
||||||
Trigger an error response based on the sender (From / Sender).
|
|
||||||
</div>
|
|
||||||
<div class="row mt-1">
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">
|
|
||||||
Response code
|
|
||||||
</label>
|
|
||||||
<input type="number" class="form-control"
|
|
||||||
v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">
|
|
||||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
|
||||||
</label>
|
|
||||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
|
||||||
v-model.number="chaosConfig.Sender.Probability">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label>Trigger: <code>Recipient</code></label>
|
|
||||||
<div class="form-text">
|
|
||||||
Trigger an error response based on the recipients (To, Cc, Bcc).
|
|
||||||
</div>
|
|
||||||
<div class="row mt-1">
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">
|
|
||||||
Response code
|
|
||||||
</label>
|
|
||||||
<input type="number" class="form-control"
|
|
||||||
v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">
|
|
||||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
|
||||||
</label>
|
|
||||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
|
||||||
v-model.number="chaosConfig.Recipient.Probability">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label>Trigger: <code>Authentication</code></label>
|
|
||||||
<div class="form-text">
|
|
||||||
Trigger an authentication error response.
|
|
||||||
Note that SMTP authentication must be configured too.
|
|
||||||
</div>
|
|
||||||
<div class="row mt-1">
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">
|
|
||||||
Response code
|
|
||||||
</label>
|
|
||||||
<input type="number" class="form-control"
|
|
||||||
v-model.number="chaosConfig.Authentication.ErrorCode" min="400"
|
|
||||||
max="599" required>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">
|
|
||||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
|
||||||
</label>
|
|
||||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
|
||||||
v-model.number="chaosConfig.Authentication.Probability">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="chaosUpdated" class="mb-3 text-center">
|
|
||||||
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,221 +1,234 @@
|
|||||||
<script>
|
<script>
|
||||||
import { VcDonut } from 'vue-css-donut-chart'
|
import { VcDonut } from "vue-css-donut-chart";
|
||||||
import axios from 'axios'
|
import axios from "axios";
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from "../../mixins/CommonMixins";
|
||||||
import { Tooltip } from 'bootstrap'
|
import { Tooltip } from "bootstrap";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
},
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
VcDonut,
|
VcDonut,
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
error: false,
|
error: false,
|
||||||
check: false,
|
check: false,
|
||||||
platforms: [],
|
platforms: [],
|
||||||
allPlatforms: {
|
allPlatforms: {
|
||||||
"windows": "Windows",
|
windows: "Windows",
|
||||||
"windows-mail": "Windows Mail",
|
"windows-mail": "Windows Mail",
|
||||||
"outlook-com": "Outlook.com",
|
"outlook-com": "Outlook.com",
|
||||||
"macos": "macOS",
|
macos: "macOS",
|
||||||
"ios": "iOS",
|
ios: "iOS",
|
||||||
"android": "Android",
|
android: "Android",
|
||||||
"desktop-webmail": "Desktop Webmail",
|
"desktop-webmail": "Desktop Webmail",
|
||||||
"mobile-webmail": "Mobile Webmail",
|
"mobile-webmail": "Mobile Webmail",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.loadConfig()
|
|
||||||
this.doCheck()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
summary() {
|
summary() {
|
||||||
if (!this.check) {
|
if (!this.check) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = {
|
const result = {
|
||||||
Warnings: [],
|
Warnings: [],
|
||||||
Total: {
|
Total: {
|
||||||
Nodes: this.check.Total.Nodes
|
Nodes: this.check.Total.Nodes,
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
for (let i = 0; i < this.check.Warnings.length; i++) {
|
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
|
// for <script> test
|
||||||
if (o.Results.length == 0) {
|
if (o.Results.length === 0) {
|
||||||
result.Warnings.push(o)
|
result.Warnings.push(o);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter by enabled platforms
|
// filter by enabled platforms
|
||||||
let results = o.Results.filter((w) => {
|
const results = o.Results.filter((w) => {
|
||||||
return this.platforms.indexOf(w.Platform) != -1
|
return this.platforms.indexOf(w.Platform) !== -1;
|
||||||
})
|
});
|
||||||
|
|
||||||
if (results.length == 0) {
|
if (results.length === 0) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// recalculate the percentages
|
// recalculate the percentages
|
||||||
let y = 0, p = 0, n = 0
|
let y = 0;
|
||||||
|
let p = 0;
|
||||||
|
let n = 0;
|
||||||
|
|
||||||
results.forEach(function (r) {
|
results.forEach((r) => {
|
||||||
if (r.Support == "yes") {
|
if (r.Support === "yes") {
|
||||||
y++
|
y++;
|
||||||
} else if (r.Support == "partial") {
|
} else if (r.Support === "partial") {
|
||||||
p++
|
p++;
|
||||||
} else {
|
} else {
|
||||||
n++
|
n++;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
let total = y + p + n
|
const total = y + p + n;
|
||||||
o.Results = results
|
o.Results = results;
|
||||||
o.Score = {
|
o.Score = {
|
||||||
Found: o.Score.Found,
|
Found: o.Score.Found,
|
||||||
Supported: y / total * 100,
|
Supported: (y / total) * 100,
|
||||||
Partial: p / total * 100,
|
Partial: (p / total) * 100,
|
||||||
Unsupported: n / 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) => {
|
result.Warnings.forEach((w) => {
|
||||||
let scoreWeight = 1
|
let scoreWeight = 1;
|
||||||
if (w.Score.Found < result.Total.Nodes) {
|
if (w.Score.Found < result.Total.Nodes) {
|
||||||
// each error is weighted based on the number of occurrences vs: the total message 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
|
// 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
|
// are actually used in the HTML, and including things like bootstrap styles completely throws
|
||||||
// off the calculation as these dominate.
|
// off the calculation as these dominate.
|
||||||
if (this.isPseudoClassOrAtRule(w.Title)) {
|
if (this.isPseudoClassOrAtRule(w.Title)) {
|
||||||
scoreWeight = 0.05
|
scoreWeight = 0.05;
|
||||||
w.PseudoClassOrAtRule = true
|
w.PseudoClassOrAtRule = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let scorePartial = w.Score.Partial * scoreWeight
|
const scorePartial = w.Score.Partial * scoreWeight;
|
||||||
let scoreUnsupported = w.Score.Unsupported * scoreWeight
|
const scoreUnsupported = w.Score.Unsupported * scoreWeight;
|
||||||
if (scorePartial > maxPartial) {
|
if (scorePartial > maxPartial) {
|
||||||
maxPartial = scorePartial
|
maxPartial = scorePartial;
|
||||||
}
|
}
|
||||||
if (scoreUnsupported > maxUnsupported) {
|
if (scoreUnsupported > maxUnsupported) {
|
||||||
maxUnsupported = scoreUnsupported
|
maxUnsupported = scoreUnsupported;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// sort warnings by final score
|
// sort warnings by final score
|
||||||
result.Warnings.sort((a, b) => {
|
result.Warnings.sort((a, b) => {
|
||||||
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
|
let aWeight =
|
||||||
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
|
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)) {
|
if (this.isPseudoClassOrAtRule(a.Title)) {
|
||||||
aWeight = 0.05
|
aWeight = 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isPseudoClassOrAtRule(b.Title)) {
|
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.Supported = 100 - maxPartial - maxUnsupported;
|
||||||
result.Total.Partial = maxPartial
|
result.Total.Partial = maxPartial;
|
||||||
result.Total.Unsupported = maxUnsupported
|
result.Total.Unsupported = maxUnsupported;
|
||||||
|
|
||||||
this.$emit('setHtmlScore', result.Total.Supported)
|
this.$emit("setHtmlScore", result.Total.Supported);
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
graphSections() {
|
graphSections() {
|
||||||
let s = Math.round(this.summary.Total.Supported)
|
const s = Math.round(this.summary.Total.Supported);
|
||||||
let p = Math.round(this.summary.Total.Partial)
|
const p = Math.round(this.summary.Total.Partial);
|
||||||
let u = 100 - s - p
|
const u = 100 - s - p;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: this.round2dm(this.summary.Total.Supported) + '% supported',
|
label: this.round2dm(this.summary.Total.Supported) + "% supported",
|
||||||
value: s,
|
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,
|
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,
|
value: u,
|
||||||
color: '#dc3545'
|
color: "#dc3545",
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
// colors depend on both varying unsupported & partially unsupported percentages
|
// colors depend on both varying unsupported & partially unsupported percentages
|
||||||
scoreColor() {
|
scoreColor() {
|
||||||
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
|
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
|
||||||
this.$emit('setBadgeStyle', 'bg-success')
|
this.$emit("setBadgeStyle", "bg-success");
|
||||||
return 'text-success'
|
return "text-success";
|
||||||
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
|
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
|
||||||
this.$emit('setBadgeStyle', 'bg-warning text-primary')
|
this.$emit("setBadgeStyle", "bg-warning text-primary");
|
||||||
return 'text-warning'
|
return "text-warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('setBadgeStyle', 'bg-danger')
|
this.$emit("setBadgeStyle", "bg-danger");
|
||||||
return 'text-danger'
|
return "text-danger";
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
message: {
|
message: {
|
||||||
handler() {
|
handler() {
|
||||||
this.$emit('setHtmlScore', false)
|
this.$emit("setHtmlScore", false);
|
||||||
this.doCheck()
|
this.doCheck();
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true,
|
||||||
},
|
},
|
||||||
platforms(v) {
|
platforms(v) {
|
||||||
localStorage.setItem('html-check-platforms', JSON.stringify(v))
|
localStorage.setItem("html-check-platforms", JSON.stringify(v));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.loadConfig();
|
||||||
|
this.doCheck();
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
doCheck() {
|
doCheck() {
|
||||||
this.check = false
|
this.check = false;
|
||||||
|
|
||||||
if (this.message.HTML == "") {
|
if (this.message.HTML === "") {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ignore any error, do not show loader
|
// 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) => {
|
.then((result) => {
|
||||||
this.check = result.data
|
this.check = result.data;
|
||||||
this.error = false
|
this.error = false;
|
||||||
|
|
||||||
// set tooltips
|
// set tooltips
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
|
||||||
}, 500)
|
}, 500);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// handle error
|
// handle error
|
||||||
@@ -223,68 +236,72 @@ export default {
|
|||||||
// The request was made and the server responded with a status code
|
// The request was made and the server responded with a status code
|
||||||
// that falls out of the range of 2xx
|
// that falls out of the range of 2xx
|
||||||
if (error.response.data.Error) {
|
if (error.response.data.Error) {
|
||||||
this.error = error.response.data.Error
|
this.error = error.response.data.Error;
|
||||||
} else {
|
} else {
|
||||||
this.error = error.response.data
|
this.error = error.response.data;
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
// The request was made but no response was received
|
// The request was made but no response was received
|
||||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||||
// http.ClientRequest in node.js
|
// 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 {
|
} else {
|
||||||
// Something happened in setting up the request that triggered an Error
|
// Something happened in setting up the request that triggered an Error
|
||||||
this.error = error.message
|
this.error = error.message;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
loadConfig() {
|
loadConfig() {
|
||||||
let platforms = localStorage.getItem('html-check-platforms')
|
const platforms = localStorage.getItem("html-check-platforms");
|
||||||
if (platforms) {
|
if (platforms) {
|
||||||
try {
|
try {
|
||||||
this.platforms = JSON.parse(platforms)
|
this.platforms = JSON.parse(platforms);
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// set all options
|
// set all options
|
||||||
if (this.platforms.length == 0) {
|
if (this.platforms.length === 0) {
|
||||||
this.platforms = Object.keys(this.allPlatforms)
|
this.platforms = Object.keys(this.allPlatforms);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// return a platform's families (email clients)
|
// return a platform's families (email clients)
|
||||||
families(k) {
|
families(k) {
|
||||||
if (this.check.Platforms[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>)
|
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
|
||||||
isPseudoClassOrAtRule(t) {
|
isPseudoClassOrAtRule(t) {
|
||||||
return t.match(/^(:|@)/)
|
return t.match(/^(:|@)/);
|
||||||
},
|
},
|
||||||
|
|
||||||
round(v) {
|
round(v) {
|
||||||
return Math.round(v)
|
return Math.round(v);
|
||||||
},
|
},
|
||||||
|
|
||||||
round2dm(v) {
|
round2dm(v) {
|
||||||
return Math.round(v * 100) / 100
|
return Math.round(v * 100) / 100;
|
||||||
},
|
},
|
||||||
|
|
||||||
scrollToWarnings() {
|
scrollToWarnings() {
|
||||||
if (!this.$refs.warnings) {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -299,39 +316,50 @@ export default {
|
|||||||
<div class="mt-5 mb-3">
|
<div class="mt-5 mb-3">
|
||||||
<div class="row w-100">
|
<div class="row w-100">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
|
<vc-donut
|
||||||
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
|
:sections="graphSections"
|
||||||
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
|
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">
|
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||||
{{ round2dm(summary.Total.Supported) }}%
|
{{ round2dm(summary.Total.Supported) }}%
|
||||||
</h2>
|
</h2>
|
||||||
<div class="text-body">
|
<div class="text-body">support</div>
|
||||||
support
|
|
||||||
</div>
|
|
||||||
<template #legend>
|
<template #legend>
|
||||||
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
|
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
|
||||||
<span class="text-nowrap">
|
<span class="text-nowrap">
|
||||||
<i class="bi bi-circle-fill text-success"></i>
|
<i class="bi bi-circle-fill text-success"></i>
|
||||||
{{ round2dm(summary.Total.Supported) }}% supported
|
{{ round2dm(summary.Total.Supported) }}% supported
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="text-nowrap">
|
<span class="text-nowrap">
|
||||||
<i class="bi bi-circle-fill text-warning"></i>
|
<i class="bi bi-circle-fill text-warning"></i>
|
||||||
{{ round2dm(summary.Total.Partial) }}% partially supported
|
{{ round2dm(summary.Total.Partial) }}% partially supported
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="text-nowrap">
|
<span class="text-nowrap">
|
||||||
<i class="bi bi-circle-fill text-danger"></i>
|
<i class="bi bi-circle-fill text-danger"></i>
|
||||||
{{ round2dm(summary.Total.Unsupported) }}% not supported
|
{{ round2dm(summary.Total.Unsupported) }}% not supported
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="small text-muted">
|
<p class="small text-muted">calculated from {{ formatNumber(check.Total.Tests) }} tests</p>
|
||||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
</vc-donut>
|
</vc-donut>
|
||||||
|
|
||||||
<div class="input-group justify-content-center mb-3">
|
<div class="input-group justify-content-center mb-3">
|
||||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#AboutHTMLCheckResults">
|
class="btn btn-outline-secondary"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#AboutHTMLCheckResults"
|
||||||
|
>
|
||||||
<i class="bi bi-info-circle-fill"></i>
|
<i class="bi bi-info-circle-fill"></i>
|
||||||
Help
|
Help
|
||||||
</button>
|
</button>
|
||||||
@@ -339,12 +367,24 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h2 class="h5 mb-3">Tested platforms:</h2>
|
<h2 class="h5 mb-3">Tested platforms:</h2>
|
||||||
<div class="form-check form-switch" v-for="p, k in allPlatforms">
|
<div v-for="(p, k) in allPlatforms" :key="'check_' + k" class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms"
|
<input
|
||||||
:aria-label="p" :id="'Check_' + k">
|
:id="'Check_' + k"
|
||||||
<label class="form-check-label" :for="'Check_' + k"
|
v-model="platforms"
|
||||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'" :title="families(k).join(', ')"
|
class="form-check-input"
|
||||||
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
|
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 }}
|
{{ p }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,45 +396,72 @@ export default {
|
|||||||
<h4 ref="warnings" class="h5 mt-4">
|
<h4 ref="warnings" class="h5 mt-4">
|
||||||
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
|
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
|
||||||
</h4>
|
</h4>
|
||||||
<div class="accordion" id="warnings">
|
<div id="warnings" class="accordion">
|
||||||
<div class="accordion-item" v-for="warning in summary.Warnings">
|
<div v-for="(warning, i) in summary.Warnings" :key="'warning_' + i" class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug">
|
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="row w-100 w-lg-75">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
{{ warning.Title }}
|
{{ warning.Title }}
|
||||||
<span class="ms-2 small badge text-bg-secondary" title="Test category">
|
<span class="ms-2 small badge text-bg-secondary" title="Test category">
|
||||||
{{ warning.Category }}
|
{{ warning.Category }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ms-2 small badge text-bg-light"
|
<span
|
||||||
title="The number of times this was detected">
|
class="ms-2 small badge text-bg-light"
|
||||||
|
title="The number of times this was detected"
|
||||||
|
>
|
||||||
x {{ warning.Score.Found }}
|
x {{ warning.Score.Found }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm mt-2 mt-sm-0">
|
<div class="col-sm mt-2 mt-sm-0">
|
||||||
<div class="progress-stacked">
|
<div class="progress-stacked">
|
||||||
<div class="progress" role="progressbar" aria-label="Supported"
|
<div
|
||||||
:aria-valuenow="warning.Score.Supported" aria-valuemin="0"
|
class="progress"
|
||||||
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }"
|
role="progressbar"
|
||||||
title="Supported">
|
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">
|
<div class="progress-bar bg-success">
|
||||||
{{ round(warning.Score.Supported) + '%' }}
|
{{ round(warning.Score.Supported) + "%" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" role="progressbar" aria-label="Partial"
|
<div
|
||||||
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100"
|
class="progress"
|
||||||
:style="{ width: warning.Score.Partial + '%' }" title="Partial support">
|
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">
|
<div class="progress-bar progress-bar-striped bg-warning text-dark">
|
||||||
{{ round(warning.Score.Partial) + '%' }}
|
{{ round(warning.Score.Partial) + "%" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" role="progressbar" aria-label="No"
|
<div
|
||||||
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0"
|
class="progress"
|
||||||
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }"
|
role="progressbar"
|
||||||
title="Not supported">
|
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">
|
<div class="progress-bar bg-danger">
|
||||||
{{ round(warning.Score.Unsupported) + '%' }}
|
{{ round(warning.Score.Unsupported) + "%" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,28 +471,45 @@ export default {
|
|||||||
</h2>
|
</h2>
|
||||||
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
|
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
|
||||||
<div class="accordion-body">
|
<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">
|
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
|
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
|
||||||
propert<template v-if="warning.Score.Found === 1">y</template><template
|
<template v-if="warning.Score.Found === 1">property</template>
|
||||||
v-else>ies</template> in the CSS
|
<template v-else>properties</template>
|
||||||
styles, but unable to test if used or not.
|
in the CSS styles, but unable to test if used or not.
|
||||||
</span>
|
</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>
|
</p>
|
||||||
|
|
||||||
<template v-if="warning.Results.length">
|
<template v-if="warning.Results.length">
|
||||||
<h3 class="h6">Clients with partial or no support:</h3>
|
<h3 class="h6">Clients with partial or no support:</h3>
|
||||||
<p>
|
<p>
|
||||||
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4">
|
<small
|
||||||
<i class="bi bi-circle-fill"
|
v-for="(warningRes, wi) in warning.Results"
|
||||||
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'"
|
:key="'warning_results_' + wi"
|
||||||
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i>
|
class="text-nowrap d-inline-block me-4"
|
||||||
{{ warning.Name }}
|
>
|
||||||
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''"
|
<i
|
||||||
title="See notes">
|
class="bi bi-circle-fill"
|
||||||
{{ warning.NoteNumber }}
|
: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>
|
</span>
|
||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
@@ -433,17 +517,21 @@ export default {
|
|||||||
|
|
||||||
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
|
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
|
||||||
<h3 class="h6">Notes:</h3>
|
<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">
|
<div class="col-auto pe-0">
|
||||||
<span class="badge text-bg-secondary">
|
<span class="badge text-bg-secondary">
|
||||||
{{ i }}
|
{{ ni }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col" v-html="n"></div>
|
<div class="col" v-html="sanitizeHTML(n)"></div>
|
||||||
</div>
|
</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>
|
<a :href="warning.URL" target="_blank">Online reference</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,30 +540,44 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-center text-muted small mt-4">
|
<p class="text-center text-muted small mt-4">
|
||||||
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using
|
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using compatibility data
|
||||||
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel"
|
<div
|
||||||
aria-hidden="true">
|
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-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="accordion" id="HTMLCheckAboutAccordion">
|
<div id="HTMLCheckAboutAccordion" class="accordion">
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
class="accordion-button collapsed"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col1"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="col1"
|
||||||
|
>
|
||||||
What is HTML check?
|
What is HTML check?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col1" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
id="col1"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
The support for HTML/CSS messages varies greatly across email clients. HTML
|
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
|
check attempts to calculate the overall support for your email for all selected
|
||||||
@@ -485,13 +587,22 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
class="accordion-button collapsed"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col2"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="col2"
|
||||||
|
>
|
||||||
How does it work?
|
How does it work?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col2" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
id="col2"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
Internally the original HTML message is run against
|
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
|
CSS support is very difficult to programmatically test, especially if a
|
||||||
message contains CSS style blocks or is linked to remote stylesheets. Remote
|
message contains CSS style blocks or is linked to remote stylesheets. Remote
|
||||||
stylesheets are, unless blocked via
|
stylesheets are, unless blocked via
|
||||||
<code>--block-remote-css-and-fonts</code>,
|
<code>--block-remote-css-and-fonts</code>, downloaded and injected into the
|
||||||
downloaded and injected into the message as style blocks. The email is then
|
message as style blocks. The email is then
|
||||||
<a href="https://github.com/vanng822/go-premailer"
|
<a href="https://github.com/vanng822/go-premailer" target="_blank"
|
||||||
target="_blank">inlined</a>
|
>inlined</a
|
||||||
|
>
|
||||||
to matching HTML elements. This gives Mailpit fairly accurate results.
|
to matching HTML elements. This gives Mailpit fairly accurate results.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -528,13 +640,22 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
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?
|
Is the final score accurate?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col3" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
id="col3"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
There are many ways to define "accurate", and how one should calculate the
|
There are many ways to define "accurate", and how one should calculate the
|
||||||
@@ -578,13 +699,22 @@ export default {
|
|||||||
|
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
class="accordion-button collapsed"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col4"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="col4"
|
||||||
|
>
|
||||||
What about invalid HTML?
|
What about invalid HTML?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col4" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
id="col4"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
HTML check does not detect if the original HTML is valid. In order to detect
|
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
|
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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<script>
|
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
headers: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
|
|
||||||
this.get(uri, false, (response) => {
|
|
||||||
this.headers = response.data
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="headers" class="small">
|
|
||||||
<div v-for="values, k in headers" class="row mb-2 pb-2 border-bottom w-100">
|
|
||||||
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
|
|
||||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
|
||||||
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
import axios from 'axios'
|
import axios from "axios";
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from "../../mixins/CommonMixins";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [commonMixins],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
message: Object,
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ["setLinkErrors"],
|
emits: ["setLinkErrors"],
|
||||||
|
|
||||||
mixins: [commonMixins],
|
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
error: false,
|
error: false,
|
||||||
@@ -19,116 +22,116 @@ export default {
|
|||||||
check: false,
|
check: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
loading: 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: {
|
computed: {
|
||||||
groupedStatuses() {
|
groupedStatuses() {
|
||||||
let results = {}
|
const results = {};
|
||||||
|
|
||||||
if (!this.check) {
|
if (!this.check) {
|
||||||
return results
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// group by status
|
// group by status
|
||||||
this.check.Links.forEach(function (r) {
|
this.check.Links.forEach((r) => {
|
||||||
if (!results[r.StatusCode]) {
|
if (!results[r.StatusCode]) {
|
||||||
let css = ""
|
let css = "";
|
||||||
if (r.StatusCode >= 400 || r.StatusCode === 0) {
|
if (r.StatusCode >= 400 || r.StatusCode === 0) {
|
||||||
css = "text-danger"
|
css = "text-danger";
|
||||||
} else if (r.StatusCode >= 300) {
|
} else if (r.StatusCode >= 300) {
|
||||||
css = "text-info"
|
css = "text-info";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.StatusCode === 0) {
|
if (r.StatusCode === 0) {
|
||||||
r.Status = 'Cannot connect to server'
|
r.Status = "Cannot connect to server";
|
||||||
}
|
}
|
||||||
results[r.StatusCode] = {
|
results[r.StatusCode] = {
|
||||||
StatusCode: r.StatusCode,
|
StatusCode: r.StatusCode,
|
||||||
Status: r.Status,
|
Status: r.Status,
|
||||||
Class: css,
|
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) {
|
for (const i in results) {
|
||||||
newArr.push(results[i])
|
newArr.push(results[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort statuses
|
// sort statuses
|
||||||
let sorted = newArr.sort((a, b) => {
|
const sorted = newArr.sort((a, b) => {
|
||||||
if (a.StatusCode === 0) {
|
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: {
|
methods: {
|
||||||
doCheck() {
|
doCheck() {
|
||||||
this.check = false
|
this.check = false;
|
||||||
this.loading = true
|
this.loading = true;
|
||||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
|
let uri = this.resolve("/api/v1/message/" + this.message.ID + "/link-check");
|
||||||
if (this.followRedirects) {
|
if (this.followRedirects) {
|
||||||
uri += '?follow=true'
|
uri += "?follow=true";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ignore any error, do not show loader
|
// ignore any error, do not show loader
|
||||||
axios.get(uri, null)
|
axios
|
||||||
|
.get(uri, null)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.check = result.data
|
this.check = result.data;
|
||||||
this.error = false
|
this.error = false;
|
||||||
|
|
||||||
this.$emit('setLinkErrors', result.data.Errors)
|
this.$emit("setLinkErrors", result.data.Errors);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// handle error
|
// handle error
|
||||||
@@ -136,27 +139,27 @@ export default {
|
|||||||
// The request was made and the server responded with a status code
|
// The request was made and the server responded with a status code
|
||||||
// that falls out of the range of 2xx
|
// that falls out of the range of 2xx
|
||||||
if (error.response.data.Error) {
|
if (error.response.data.Error) {
|
||||||
this.error = error.response.data.Error
|
this.error = error.response.data.Error;
|
||||||
} else {
|
} else {
|
||||||
this.error = error.response.data
|
this.error = error.response.data;
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
// The request was made but no response was received
|
// The request was made but no response was received
|
||||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||||
// http.ClientRequest in node.js
|
// 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 {
|
} else {
|
||||||
// Something happened in setting up the request that triggered an Error
|
// Something happened in setting up the request that triggered an Error
|
||||||
this.error = error.message
|
this.error = error.message;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
// always run
|
// always run
|
||||||
this.loading = false
|
this.loading = false;
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -164,24 +167,24 @@ export default {
|
|||||||
<div class="row mb-3 align-items-center">
|
<div class="row mb-3 align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h4 class="mb-0">
|
<h4 class="mb-0">
|
||||||
<template v-if="!check">
|
<template v-if="!check"> Link check </template>
|
||||||
Link check
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="check.Links.length">
|
<template v-if="check.Links.length">
|
||||||
Scanned {{ formatNumber(check.Links.length) }}
|
Scanned {{ formatNumber(check.Links.length) }} link<template v-if="check.Links.length != 1"
|
||||||
link<template v-if="check.Links.length != 1">s</template>
|
>s</template
|
||||||
</template>
|
>
|
||||||
<template v-else>
|
|
||||||
No links detected
|
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else> No links detected </template>
|
||||||
</template>
|
</template>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
<button
|
||||||
data-bs-target="#AboutLinkCheckResults">
|
class="btn btn-outline-secondary"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#AboutLinkCheckResults"
|
||||||
|
>
|
||||||
<i class="bi bi-info-circle-fill"></i>
|
<i class="bi bi-info-circle-fill"></i>
|
||||||
Help
|
Help
|
||||||
</button>
|
</button>
|
||||||
@@ -195,12 +198,12 @@ export default {
|
|||||||
|
|
||||||
<div v-if="!check">
|
<div v-if="!check">
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Link check scans your email text & HTML for unique links, testing the response status codes.
|
Link check scans your email text & HTML for unique links, testing the response status codes. This
|
||||||
This includes links to images and remote CSS stylesheets.
|
includes links to images and remote CSS stylesheets.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-center my-5">
|
<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">
|
<template v-if="loading">
|
||||||
Checking links
|
Checking links
|
||||||
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
|
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
|
||||||
@@ -215,14 +218,14 @@ export default {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 mb-3">
|
||||||
<div class="card-header h4" :class="s.Class">
|
<div class="card-header h4" :class="s.Class">
|
||||||
Status {{ s.StatusCode }}
|
Status {{ s.StatusCode }}
|
||||||
<small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small>
|
<small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-group list-group-flush">
|
<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>
|
<a :href="u" target="_blank" class="no-icon">{{ u }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -235,22 +238,31 @@ export default {
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel"
|
<div
|
||||||
aria-hidden="true">
|
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-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
|
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
|
||||||
<div class="form-check form-switch mb-4">
|
<div class="form-check form-switch mb-4">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects"
|
<input
|
||||||
id="LinkCheckFollowRedirectsSwitch">
|
id="LinkCheckFollowRedirectsSwitch"
|
||||||
|
v-model="followRedirects"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
/>
|
||||||
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
|
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
|
||||||
<template v-if="followRedirects">Following HTTP redirects</template>
|
<template v-if="followRedirects">Following HTTP redirects</template>
|
||||||
<template v-else>Not 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>
|
<h6 class="mt-4">Automatic link checking</h6>
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" v-model="autoScan"
|
<input
|
||||||
id="LinkCheckAutoCheckSwitch">
|
id="LinkCheckAutoCheckSwitch"
|
||||||
|
v-model="autoScan"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
/>
|
||||||
<label class="form-check-label" for="LinkCheckAutoCheckSwitch">
|
<label class="form-check-label" for="LinkCheckAutoCheckSwitch">
|
||||||
<template v-if="autoScan">Automatic link checking is enabled</template>
|
<template v-if="autoScan">Automatic link checking is enabled</template>
|
||||||
<template v-else>Automatic link checking is disabled</template>
|
<template v-else>Automatic link checking is disabled</template>
|
||||||
@@ -270,7 +287,6 @@ export default {
|
|||||||
Only enable this if you understand the potential risks & consequences.
|
Only enable this if you understand the potential risks & consequences.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
@@ -279,25 +295,39 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="AboutLinkCheckResults" tabindex="-1" aria-labelledby="AboutLinkCheckResultsLabel"
|
<div
|
||||||
aria-hidden="true">
|
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-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="accordion" id="LinkCheckAboutAccordion">
|
<div id="LinkCheckAboutAccordion" class="accordion">
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
class="accordion-button collapsed"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col1"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="col1"
|
||||||
|
>
|
||||||
What is Link check?
|
What is Link check?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col1" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#LinkCheckAboutAccordion">
|
id="col1"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#LinkCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
Link check scans your message HTML and text for all unique links, images and linked
|
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
|
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
|
||||||
@@ -307,35 +337,52 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
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?
|
What are "301" and "302" links?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col2" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#LinkCheckAboutAccordion">
|
id="col2"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#LinkCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
These are links that redirect you to another URL, for example newsletters
|
These are links that redirect you to another URL, for example newsletters often
|
||||||
often use redirect links to track user clicks.
|
use redirect links to track user clicks.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
By default Link check will not follow these links, however you can turn this on
|
By default Link check will not follow these links, however you can turn this on
|
||||||
via
|
via the settings and Link check will "follow" those redirects.
|
||||||
the settings and Link check will "follow" those redirects.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
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?
|
Why are some links returning an error but work in my browser?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col3" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#LinkCheckAboutAccordion">
|
id="col3"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#LinkCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>This may be due to various reasons, for instance:</p>
|
<p>This may be due to various reasons, for instance:</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -345,20 +392,29 @@ export default {
|
|||||||
The webserver is blocking requests that don't come from authenticated web
|
The webserver is blocking requests that don't come from authenticated web
|
||||||
browsers.
|
browsers.
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
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?
|
What are the risks of running Link check automatically?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col4" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#LinkCheckAboutAccordion">
|
id="col4"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#LinkCheckAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
Depending on the type of messages you are testing, opening all links on all
|
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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -1,657 +0,0 @@
|
|||||||
<script>
|
|
||||||
import Attachments from './Attachments.vue'
|
|
||||||
import Headers from './Headers.vue'
|
|
||||||
import HTMLCheck from './HTMLCheck.vue'
|
|
||||||
import LinkCheck from './LinkCheck.vue'
|
|
||||||
import SpamAssassin from './SpamAssassin.vue'
|
|
||||||
import Tags from 'bootstrap5-tags'
|
|
||||||
import { Tooltip } from 'bootstrap'
|
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
|
||||||
import { mailbox } from '../../stores/mailbox'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import hljs from 'highlight.js/lib/core'
|
|
||||||
import xml from 'highlight.js/lib/languages/xml'
|
|
||||||
|
|
||||||
hljs.registerLanguage('html', xml)
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
},
|
|
||||||
|
|
||||||
components: {
|
|
||||||
Attachments,
|
|
||||||
Headers,
|
|
||||||
HTMLCheck,
|
|
||||||
LinkCheck,
|
|
||||||
SpamAssassin,
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
mailbox,
|
|
||||||
srcURI: false,
|
|
||||||
iframes: [], // for resizing
|
|
||||||
canSaveTags: false, // prevent auto-saving tags on render
|
|
||||||
availableTags: [],
|
|
||||||
messageTags: [],
|
|
||||||
loadHeaders: false,
|
|
||||||
htmlScore: false,
|
|
||||||
htmlScoreColor: false,
|
|
||||||
linkCheckErrors: false,
|
|
||||||
spamScore: false,
|
|
||||||
spamScoreColor: false,
|
|
||||||
showMobileButtons: false,
|
|
||||||
showUnsubscribe: false,
|
|
||||||
scaleHTMLPreview: 'display',
|
|
||||||
// keys names match bootstrap icon names
|
|
||||||
responsiveSizes: {
|
|
||||||
phone: 'width: 322px; height: 570px',
|
|
||||||
tablet: 'width: 768px; height: 1024px',
|
|
||||||
display: 'width: 100%; height: 100%',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
messageTags() {
|
|
||||||
if (this.canSaveTags) {
|
|
||||||
// save changes to tags
|
|
||||||
this.saveTags()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
scaleHTMLPreview(v) {
|
|
||||||
if (v == 'display') {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.resizeIFrames()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
hasAnyChecksEnabled() {
|
|
||||||
return (mailbox.showHTMLCheck && this.message.HTML)
|
|
||||||
|| mailbox.showLinkCheck
|
|
||||||
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
|
||||||
},
|
|
||||||
|
|
||||||
// remove bad HTML, JavaScript, iframes etc
|
|
||||||
sanitizedHTML() {
|
|
||||||
// set target & rel on all links
|
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
||||||
if (node.tagName != 'A' || (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ('target' in node) {
|
|
||||||
node.setAttribute('target', '_blank');
|
|
||||||
node.setAttribute('rel', 'noopener noreferrer');
|
|
||||||
}
|
|
||||||
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
|
|
||||||
node.setAttribute('xlink:show', '_blank');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const clean = DOMPurify.sanitize(
|
|
||||||
this.message.HTML,
|
|
||||||
{
|
|
||||||
WHOLE_DOCUMENT: true,
|
|
||||||
SANITIZE_DOM: false,
|
|
||||||
ADD_TAGS: [
|
|
||||||
'link',
|
|
||||||
'meta',
|
|
||||||
'o:p',
|
|
||||||
'style',
|
|
||||||
],
|
|
||||||
ADD_ATTR: [
|
|
||||||
'bordercolor',
|
|
||||||
'charset',
|
|
||||||
'content',
|
|
||||||
'hspace',
|
|
||||||
'http-equiv',
|
|
||||||
'itemprop',
|
|
||||||
'itemscope',
|
|
||||||
'itemtype',
|
|
||||||
'link',
|
|
||||||
'vertical-align',
|
|
||||||
'vlink',
|
|
||||||
'vspace',
|
|
||||||
'xml:lang',
|
|
||||||
],
|
|
||||||
FORBID_ATTR: ['script'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// for debugging
|
|
||||||
// this.debugDOMPurify(DOMPurify.removed)
|
|
||||||
|
|
||||||
return clean
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.canSaveTags = false
|
|
||||||
this.messageTags = this.message.Tags
|
|
||||||
this.renderUI()
|
|
||||||
|
|
||||||
window.addEventListener("resize", this.resizeIFrames)
|
|
||||||
|
|
||||||
let headersTab = document.getElementById('nav-headers-tab')
|
|
||||||
headersTab.addEventListener('shown.bs.tab', (event) => {
|
|
||||||
this.loadHeaders = true
|
|
||||||
})
|
|
||||||
|
|
||||||
let rawTab = document.getElementById('nav-raw-tab')
|
|
||||||
rawTab.addEventListener('shown.bs.tab', (event) => {
|
|
||||||
this.srcURI = this.resolve('/api/v1/message/' + this.message.ID + '/raw')
|
|
||||||
this.resizeIFrames()
|
|
||||||
})
|
|
||||||
|
|
||||||
// manually refresh tags
|
|
||||||
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
|
|
||||||
this.availableTags = response.data
|
|
||||||
this.$nextTick(() => {
|
|
||||||
Tags.init('select[multiple]')
|
|
||||||
// delay tag change detection to allow Tags to load
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.canSaveTags = true
|
|
||||||
}, 200)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
isHTMLTabSelected() {
|
|
||||||
this.showMobileButtons = this.$refs.navhtml
|
|
||||||
&& this.$refs.navhtml.classList.contains('active')
|
|
||||||
},
|
|
||||||
|
|
||||||
renderUI() {
|
|
||||||
// activate the first non-disabled tab
|
|
||||||
document.querySelector('#nav-tab button:not([disabled])').click()
|
|
||||||
document.activeElement.blur() // blur focus
|
|
||||||
document.getElementById('message-view').scrollTop = 0
|
|
||||||
|
|
||||||
this.isHTMLTabSelected()
|
|
||||||
|
|
||||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
|
|
||||||
listObj.addEventListener('shown.bs.tab', (event) => {
|
|
||||||
this.isHTMLTabSelected()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
|
||||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
|
||||||
|
|
||||||
// delay 0.5s until vue has rendered the iframe content
|
|
||||||
window.setTimeout(() => {
|
|
||||||
let p = document.getElementById('preview-html')
|
|
||||||
if (p && typeof p.contentWindow.document.body == 'object') {
|
|
||||||
try {
|
|
||||||
// make links open in new window
|
|
||||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
|
||||||
for (var i = 0; i < anchorEls.length; i++) {
|
|
||||||
let anchorEl = anchorEls[i]
|
|
||||||
let href = anchorEl.getAttribute('href')
|
|
||||||
|
|
||||||
if (href && href.match(/^https?:\/\//i)) {
|
|
||||||
anchorEl.setAttribute('target', '_blank')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) { }
|
|
||||||
this.resizeIFrames()
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
// HTML highlighting
|
|
||||||
hljs.highlightAll()
|
|
||||||
},
|
|
||||||
|
|
||||||
resizeIframe(el) {
|
|
||||||
let i = el.target
|
|
||||||
if (typeof i.contentWindow.document.body.scrollHeight == 'number') {
|
|
||||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
resizeIFrames() {
|
|
||||||
if (this.scaleHTMLPreview != 'display') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let h = document.getElementById('preview-html')
|
|
||||||
if (h) {
|
|
||||||
if (typeof h.contentWindow.document.body.scrollHeight == 'number') {
|
|
||||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
// set the iframe body & text colors based on current theme
|
|
||||||
initRawIframe(el) {
|
|
||||||
let bodyStyles = window.getComputedStyle(document.body, null)
|
|
||||||
let bg = bodyStyles.getPropertyValue('background-color')
|
|
||||||
let txt = bodyStyles.getPropertyValue('color')
|
|
||||||
|
|
||||||
let body = el.target.contentWindow.document.querySelector('body')
|
|
||||||
if (body) {
|
|
||||||
body.style.color = txt
|
|
||||||
body.style.backgroundColor = bg
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resizeIframe(el)
|
|
||||||
},
|
|
||||||
|
|
||||||
// this function is unused but kept here to use for debugging
|
|
||||||
debugDOMPurify(removed) {
|
|
||||||
if (!removed.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignoreNodes = ['target', 'base', 'script', 'v:shapes']
|
|
||||||
|
|
||||||
let d = removed.filter((r) => {
|
|
||||||
if (typeof r.attribute != 'undefined' &&
|
|
||||||
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith('xmlns:'))
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// inline comments
|
|
||||||
if (typeof r.element != 'undefined' && (r.element.nodeType == 8 || r.element.tagName == 'SCRIPT')) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (d.length) {
|
|
||||||
console.log(d)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
saveTags() {
|
|
||||||
var data = {
|
|
||||||
IDs: [this.message.ID],
|
|
||||||
Tags: this.messageTags
|
|
||||||
}
|
|
||||||
|
|
||||||
this.put(this.resolve('/api/v1/tags'), data, (response) => {
|
|
||||||
window.scrollInPlace = true
|
|
||||||
this.$emit('loadMessages')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// Convert plain text to HTML including anchor links
|
|
||||||
textToHTML(s) {
|
|
||||||
let html = s
|
|
||||||
|
|
||||||
// full links with http(s)
|
|
||||||
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim
|
|
||||||
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
|
|
||||||
|
|
||||||
// plain www links without https?:// prefix
|
|
||||||
let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim
|
|
||||||
html = html.replace(re2, '$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲')
|
|
||||||
|
|
||||||
// escape to HTML & convert <>" back
|
|
||||||
html = html
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/˱˱˱/g, '<')
|
|
||||||
.replace(/˲˲˲/g, '>')
|
|
||||||
.replace(/ˠˠˠ/g, '"')
|
|
||||||
|
|
||||||
return html
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
|
||||||
<div class="row w-100">
|
|
||||||
<div class="col-md">
|
|
||||||
<table class="messageHeaders">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th class="small">From</th>
|
|
||||||
<td class="privacy">
|
|
||||||
<span v-if="message.From">
|
|
||||||
<span v-if="message.From.Name" class="text-spaces">
|
|
||||||
{{ message.From.Name + " " }}
|
|
||||||
</span>
|
|
||||||
<span v-if="message.From.Address" class="small">
|
|
||||||
<<a :href="searchURI(message.From.Address)" class="text-body">
|
|
||||||
{{ message.From.Address }}
|
|
||||||
</a>>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
[ Unknown ]
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span v-if="message.ListUnsubscribe.Header != ''" class="small ms-3 link"
|
|
||||||
:title="showUnsubscribe ? 'Hide unsubscribe information' : 'Show unsubscribe information'"
|
|
||||||
@click="showUnsubscribe = !showUnsubscribe">
|
|
||||||
Unsubscribe
|
|
||||||
<i class="bi bi bi-info-circle"
|
|
||||||
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"></i>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="small">
|
|
||||||
<th>To</th>
|
|
||||||
<td class="privacy">
|
|
||||||
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
|
|
||||||
<template v-if="i > 0">, </template>
|
|
||||||
<span>
|
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
|
||||||
<<a :href="searchURI(t.Address)" class="text-body">
|
|
||||||
{{ t.Address }}
|
|
||||||
</a>>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
|
||||||
<th>Cc</th>
|
|
||||||
<td class="privacy">
|
|
||||||
<span v-for="(t, i) in message.Cc">
|
|
||||||
<template v-if="i > 0">,</template>
|
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
|
||||||
<<a :href="searchURI(t.Address)" class="text-body">
|
|
||||||
{{ t.Address }}
|
|
||||||
</a>>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
|
||||||
<th>Bcc</th>
|
|
||||||
<td class="privacy">
|
|
||||||
<span v-for="(t, i) in message.Bcc">
|
|
||||||
<template v-if="i > 0">,</template>
|
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
|
||||||
<<a :href="searchURI(t.Address)" class="text-body">
|
|
||||||
{{ t.Address }}
|
|
||||||
</a>>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
|
||||||
<th class="text-nowrap">Reply-To</th>
|
|
||||||
<td class="privacy text-body-secondary text-break">
|
|
||||||
<span v-for="(t, i) in message.ReplyTo">
|
|
||||||
<template v-if="i > 0">,</template>
|
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
|
||||||
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
|
||||||
{{ t.Address }}
|
|
||||||
</a>>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address"
|
|
||||||
class="small">
|
|
||||||
<th class="text-nowrap">Return-Path</th>
|
|
||||||
<td class="privacy text-body-secondary text-break">
|
|
||||||
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
|
||||||
{{ message.ReturnPath }}
|
|
||||||
</a>>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="small">Subject</th>
|
|
||||||
<td>
|
|
||||||
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
|
|
||||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="small">
|
|
||||||
<th class="small">Date</th>
|
|
||||||
<td>
|
|
||||||
{{ messageDate(message.Date) }}
|
|
||||||
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="message.Username" class="small">
|
|
||||||
<th class="small">
|
|
||||||
Username
|
|
||||||
<i class="bi bi-exclamation-circle ms-1" data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
|
||||||
data-bs-title="The SMTP or send API username the client authenticated with">
|
|
||||||
</i>
|
|
||||||
</th>
|
|
||||||
<td class="small">
|
|
||||||
{{ message.Username }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="small">
|
|
||||||
<th>Tags</th>
|
|
||||||
<td>
|
|
||||||
<select class="form-select small tag-selector" v-model="messageTags" multiple
|
|
||||||
data-full-width="false" data-suggestions-threshold="1" data-allow-new="true"
|
|
||||||
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
|
|
||||||
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
|
|
||||||
data-separator="|,|">
|
|
||||||
<option value="">Type a tag...</option>
|
|
||||||
<!-- you need at least one option with the placeholder -->
|
|
||||||
<option v-for="t in availableTags" :value="t">{{ t }}</option>
|
|
||||||
</select>
|
|
||||||
<div class="invalid-feedback">Invalid tag name</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr v-if="message.ListUnsubscribe.Header != ''" class="small"
|
|
||||||
:class="showUnsubscribe ? '' : 'd-none'">
|
|
||||||
<th>Unsubscribe</th>
|
|
||||||
<td>
|
|
||||||
<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2">
|
|
||||||
<template v-for="(u, i) in message.ListUnsubscribe.Links">
|
|
||||||
<template v-if="i > 0">, </template>
|
|
||||||
<{{ u }}>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<i class="bi bi-info-circle text-success me-2 link"
|
|
||||||
v-if="message.ListUnsubscribe.HeaderPost != ''" data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
|
||||||
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost">
|
|
||||||
</i>
|
|
||||||
<i class="bi bi-exclamation-circle text-danger link"
|
|
||||||
v-if="message.ListUnsubscribe.Errors != ''" data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
|
||||||
:data-bs-title="message.ListUnsubscribe.Errors">
|
|
||||||
</i>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-auto d-none d-md-block text-end mt-md-3"
|
|
||||||
v-if="message.Attachments && message.Attachments.length || message.Inline && message.Inline.length">
|
|
||||||
<div class="mt-2 mt-md-0">
|
|
||||||
<template v-if="message.Attachments.length">
|
|
||||||
<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message">
|
|
||||||
Attachment<span v-if="message.Attachments.length > 1">s</span>
|
|
||||||
({{ message.Attachments.length }})
|
|
||||||
</span>
|
|
||||||
<br>
|
|
||||||
</template>
|
|
||||||
<span class="badge rounded-pill text-bg-secondary p-2" v-if="message.Inline.length"
|
|
||||||
title="Inline images in this message">
|
|
||||||
Inline image<span v-if="message.Inline.length > 1">s</span>
|
|
||||||
({{ message.Inline.length }})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav nav-tabs my-3 d-print-none" id="nav-tab" role="tablist">
|
|
||||||
<template v-if="message.HTML">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html"
|
|
||||||
type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml"
|
|
||||||
v-on:click="resizeIFrames()">
|
|
||||||
HTML
|
|
||||||
</button>
|
|
||||||
<button type="button" class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
|
||||||
data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
|
|
||||||
<span class="visually-hidden">Toggle Dropdown</span>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<button class="dropdown-item" data-bs-toggle="tab" data-bs-target="#nav-html-source"
|
|
||||||
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false">
|
|
||||||
HTML Source
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="nav-link d-none d-sm-inline" id="nav-html-source-tab" data-bs-toggle="tab"
|
|
||||||
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
|
|
||||||
aria-selected="false">
|
|
||||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
|
|
||||||
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
|
|
||||||
:class="message.HTML == '' ? 'show' : ''">
|
|
||||||
Text
|
|
||||||
</button>
|
|
||||||
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
|
|
||||||
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
|
|
||||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
|
||||||
</button>
|
|
||||||
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
|
||||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
|
||||||
Raw
|
|
||||||
</button>
|
|
||||||
<div class="dropdown d-xl-none" v-show="hasAnyChecksEnabled">
|
|
||||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
Checks
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu checks">
|
|
||||||
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
|
||||||
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
|
|
||||||
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
|
||||||
aria-selected="false">
|
|
||||||
HTML Check
|
|
||||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
|
||||||
v-if="htmlScore !== false">
|
|
||||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li v-if="mailbox.showLinkCheck">
|
|
||||||
<button class="dropdown-item" id="nav-link-check-tab" data-bs-toggle="tab"
|
|
||||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
|
||||||
aria-selected="false">
|
|
||||||
Link Check
|
|
||||||
<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
|
||||||
<small>0</small>
|
|
||||||
</span>
|
|
||||||
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
|
||||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
|
||||||
<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab"
|
|
||||||
data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
|
||||||
aria-selected="false">
|
|
||||||
Spam Analysis
|
|
||||||
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
|
||||||
v-if="spamScore !== false">
|
|
||||||
<small>{{ spamScore }}</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
|
||||||
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
|
||||||
aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
|
||||||
HTML Check
|
|
||||||
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
|
|
||||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
|
|
||||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
|
||||||
aria-selected="false" v-if="mailbox.showLinkCheck">
|
|
||||||
Link Check
|
|
||||||
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
|
|
||||||
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
|
|
||||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
|
||||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
|
||||||
aria-selected="false" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
|
||||||
Spam Analysis
|
|
||||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
|
||||||
<small>{{ spamScore }}</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
|
||||||
<template v-for="_, key in responsiveSizes">
|
|
||||||
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
|
||||||
v-on:click="scaleHTMLPreview = key">
|
|
||||||
<i class="bi" :class="'bi-' + key"></i>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="tab-content mb-5" id="nav-tabContent">
|
|
||||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
|
||||||
aria-labelledby="nav-html-tab" tabindex="0">
|
|
||||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
|
||||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
|
|
||||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
|
||||||
:attachments="allAttachments(message)">
|
|
||||||
</Attachments>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
|
||||||
tabindex="0" v-if="message.HTML">
|
|
||||||
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
|
|
||||||
tabindex="0" :class="message.HTML == '' ? 'show' : ''">
|
|
||||||
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
|
||||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
|
||||||
:attachments="allAttachments(message)">
|
|
||||||
</Attachments>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
|
||||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
|
||||||
<iframe v-if="srcURI" :src="srcURI" v-on:load="initRawIframe" frameborder="0"
|
|
||||||
style="width: 100%; height: 300px"></iframe>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
|
||||||
tabindex="0">
|
|
||||||
<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''" :message="message"
|
|
||||||
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
|
|
||||||
tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
|
||||||
<SpamAssassin :message="message" @setSpamScore="(n) => spamScore = n"
|
|
||||||
@set-badge-style="(v) => spamScoreColor = v" />
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
|
||||||
tabindex="0" v-if="mailbox.showLinkCheck">
|
|
||||||
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,84 +1,102 @@
|
|||||||
<script>
|
<script>
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from "../../mixins/CommonMixins";
|
||||||
import ICAL from "ical.js"
|
import ICAL from "ical.js";
|
||||||
import dayjs from 'dayjs'
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
attachments: Object
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ical: false
|
ical: false,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
openAttachment(part, e) {
|
openAttachment(part, e) {
|
||||||
let filename = part.FileName
|
const filename = part.FileName;
|
||||||
let contentType = part.ContentType
|
const contentType = part.ContentType;
|
||||||
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)
|
const href = this.resolve("/api/v1/message/" + this.message.ID + "/part/" + part.PartID);
|
||||||
if (filename.match(/\.ics$/i) || contentType == 'text/calendar') {
|
if (filename.match(/\.ics$/i) || contentType === "text/calendar") {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
|
|
||||||
this.get(href, null, (response) => {
|
this.get(href, null, (response) => {
|
||||||
let comp = new ICAL.Component(ICAL.parse(response.data))
|
const comp = new ICAL.Component(ICAL.parse(response.data));
|
||||||
let vevent = comp.getFirstSubcomponent('vevent')
|
const vevent = comp.getFirstSubcomponent("vevent");
|
||||||
if (!vevent) {
|
if (!vevent) {
|
||||||
alert('Error parsing ICS file')
|
alert("Error parsing ICS file");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
let event = new ICAL.Event(vevent)
|
const event = new ICAL.Event(vevent);
|
||||||
|
|
||||||
let summary = {}
|
const summary = {};
|
||||||
summary.link = href
|
summary.link = href;
|
||||||
summary.status = vevent.getFirstPropertyValue('status')
|
summary.status = vevent.getFirstPropertyValue("status");
|
||||||
summary.url = vevent.getFirstPropertyValue('url')
|
summary.url = vevent.getFirstPropertyValue("url");
|
||||||
summary.summary = event.summary
|
summary.summary = event.summary;
|
||||||
summary.description = event.description
|
summary.description = event.description;
|
||||||
summary.location = event.location
|
summary.location = event.location;
|
||||||
summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a')
|
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.end = dayjs(event.endDate).format("ddd, D MMM YYYY, h:mm a");
|
||||||
summary.isRecurring = event.isRecurring()
|
summary.isRecurring = event.isRecurring();
|
||||||
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false
|
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, "") : false;
|
||||||
summary.attendees = []
|
summary.attendees = [];
|
||||||
event.attendees.forEach((a) => {
|
event.attendees.forEach((a) => {
|
||||||
if (a.jCal[1].cn) {
|
if (a.jCal[1].cn) {
|
||||||
summary.attendees.push(a.jCal[1].cn)
|
summary.attendees.push(a.jCal[1].cn);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
|
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
|
||||||
summary.timezone = vtimezone.getFirstPropertyValue("tzid")
|
summary.timezone = vtimezone.getFirstPropertyValue("tzid");
|
||||||
})
|
});
|
||||||
|
|
||||||
this.ical = summary
|
this.ical = summary;
|
||||||
|
|
||||||
// display modal
|
// display modal
|
||||||
this.modal('ICSView').show()
|
this.modal("ICSView").show();
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-4 border-top pt-4">
|
<div class="mt-4 border-top pt-4">
|
||||||
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
<a
|
||||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px"
|
v-for="part in attachments"
|
||||||
@click="openAttachment(part, $event)">
|
:key="part.PartID"
|
||||||
<img v-if="isImage(part)"
|
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||||
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top"
|
class="card attachment float-start me-3 mb-3"
|
||||||
alt="">
|
target="_blank"
|
||||||
<img v-else
|
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=="
|
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
|
||||||
class="card-img-top" alt="">
|
class="card-img-top"
|
||||||
<div class="icon" v-if="!isImage(part)">
|
alt=""
|
||||||
|
/>
|
||||||
|
<div v-if="!isImage(part)" class="icon">
|
||||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body border-0">
|
<div class="card-body border-0">
|
||||||
@@ -87,16 +105,16 @@ export default {
|
|||||||
<small>{{ getFileSize(part.Size) }}</small>
|
<small>{{ getFileSize(part.Size) }}</small>
|
||||||
</p>
|
</p>
|
||||||
<p class="card-text mb-0 small">
|
<p class="card-text mb-0 small">
|
||||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer small border-0 text-center text-truncate">
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -106,7 +124,7 @@ export default {
|
|||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" v-if="ical">
|
<div v-if="ical" class="modal-body">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="ical.summary">
|
<tr v-if="ical.summary">
|
||||||
@@ -126,7 +144,7 @@ export default {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-if="ical.status">
|
<tr v-if="ical.status">
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<td> {{ ical.status }}</td>
|
<td>{{ ical.status }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="ical.location">
|
<tr v-if="ical.location">
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
@@ -134,7 +152,9 @@ export default {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-if="ical.url">
|
<tr v-if="ical.url">
|
||||||
<th>URL</th>
|
<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>
|
||||||
<tr v-if="ical.organizer">
|
<tr v-if="ical.organizer">
|
||||||
<th>Organizer</th>
|
<th>Organizer</th>
|
||||||
@@ -143,7 +163,7 @@ export default {
|
|||||||
<tr v-if="ical.attendees.length">
|
<tr v-if="ical.attendees.length">
|
||||||
<th>Attendees</th>
|
<th>Attendees</th>
|
||||||
<td>
|
<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>
|
<template v-if="i > 0">,</template>
|
||||||
{{ a }}
|
{{ a }}
|
||||||
</span>
|
</span>
|
||||||
@@ -154,12 +174,9 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
<a class="btn btn-primary" target="_blank" :href="ical.link">
|
<a class="btn btn-primary" target="_blank" :href="ical.link"> Download attachment </a>
|
||||||
Download attachment
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
40
server/ui-src/components/message/MessageHeaders.vue
Normal file
40
server/ui-src/components/message/MessageHeaders.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script>
|
||||||
|
import commonMixins from "../../mixins/CommonMixins";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
headers: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
const uri = this.resolve("/api/v1/message/" + this.message.ID + "/headers");
|
||||||
|
this.get(uri, false, (response) => {
|
||||||
|
this.headers = response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="headers" class="small">
|
||||||
|
<div v-for="(values, k) in headers" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
|
||||||
|
<div class="col-md-4 col-lg-3 col-xl-2 mb-2">
|
||||||
|
<b>{{ k }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||||
|
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
861
server/ui-src/components/message/MessageItem.vue
Normal file
861
server/ui-src/components/message/MessageItem.vue
Normal file
@@ -0,0 +1,861 @@
|
|||||||
|
<script>
|
||||||
|
import Attachments from "./MessageAttachments.vue";
|
||||||
|
import Headers from "./MessageHeaders.vue";
|
||||||
|
import HTMLCheck from "./HTMLCheck.vue";
|
||||||
|
import LinkCheck from "./LinkCheck.vue";
|
||||||
|
import SpamAssassin from "./SpamAssassin.vue";
|
||||||
|
import Tags from "bootstrap5-tags";
|
||||||
|
import { Tooltip } from "bootstrap";
|
||||||
|
import commonMixins from "../../mixins/CommonMixins";
|
||||||
|
import { mailbox } from "../../stores/mailbox";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import xml from "highlight.js/lib/languages/xml";
|
||||||
|
|
||||||
|
hljs.registerLanguage("html", xml);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Attachments,
|
||||||
|
Headers,
|
||||||
|
HTMLCheck,
|
||||||
|
LinkCheck,
|
||||||
|
SpamAssassin,
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ["loadMessages"],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mailbox,
|
||||||
|
srcURI: false,
|
||||||
|
iframes: [], // for resizing
|
||||||
|
canSaveTags: false, // prevent auto-saving tags on render
|
||||||
|
availableTags: [],
|
||||||
|
messageTags: [],
|
||||||
|
loadHeaders: false,
|
||||||
|
htmlScore: false,
|
||||||
|
htmlScoreColor: false,
|
||||||
|
linkCheckErrors: false,
|
||||||
|
spamScore: false,
|
||||||
|
spamScoreColor: false,
|
||||||
|
showMobileButtons: false,
|
||||||
|
showUnsubscribe: false,
|
||||||
|
scaleHTMLPreview: "display",
|
||||||
|
// keys names match bootstrap icon names
|
||||||
|
responsiveSizes: {
|
||||||
|
phone: "width: 322px; height: 570px",
|
||||||
|
tablet: "width: 768px; height: 1024px",
|
||||||
|
display: "width: 100%; height: 100%",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
hasAnyChecksEnabled() {
|
||||||
|
return (
|
||||||
|
(mailbox.showHTMLCheck && this.message.HTML) ||
|
||||||
|
mailbox.showLinkCheck ||
|
||||||
|
(mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// remove bad HTML, JavaScript, iframes etc
|
||||||
|
sanitizedHTML() {
|
||||||
|
// set target & rel on all links
|
||||||
|
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||||
|
if (
|
||||||
|
node.tagName !== "A" ||
|
||||||
|
(node.hasAttribute("href") && node.getAttribute("href").substring(0, 1) === "#")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("target" in node) {
|
||||||
|
node.setAttribute("target", "_blank");
|
||||||
|
node.setAttribute("rel", "noopener noreferrer");
|
||||||
|
}
|
||||||
|
if (!node.hasAttribute("target") && (node.hasAttribute("xlink:href") || node.hasAttribute("href"))) {
|
||||||
|
node.setAttribute("xlink:show", "_blank");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const clean = DOMPurify.sanitize(this.message.HTML, {
|
||||||
|
WHOLE_DOCUMENT: true,
|
||||||
|
SANITIZE_DOM: false,
|
||||||
|
ADD_TAGS: ["link", "meta", "o:p", "style"],
|
||||||
|
ADD_ATTR: [
|
||||||
|
"bordercolor",
|
||||||
|
"charset",
|
||||||
|
"content",
|
||||||
|
"hspace",
|
||||||
|
"http-equiv",
|
||||||
|
"itemprop",
|
||||||
|
"itemscope",
|
||||||
|
"itemtype",
|
||||||
|
"link",
|
||||||
|
"vertical-align",
|
||||||
|
"vlink",
|
||||||
|
"vspace",
|
||||||
|
"xml:lang",
|
||||||
|
],
|
||||||
|
FORBID_ATTR: ["script"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// for debugging
|
||||||
|
// this.debugDOMPurify(DOMPurify.removed)
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
messageTags() {
|
||||||
|
if (this.canSaveTags) {
|
||||||
|
// save changes to tags
|
||||||
|
this.saveTags();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scaleHTMLPreview(v) {
|
||||||
|
if (v === "display") {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.resizeIFrames();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.canSaveTags = false;
|
||||||
|
this.messageTags = this.message.Tags;
|
||||||
|
this.renderUI();
|
||||||
|
|
||||||
|
window.addEventListener("resize", this.resizeIFrames);
|
||||||
|
|
||||||
|
const headersTab = document.getElementById("nav-headers-tab");
|
||||||
|
headersTab.addEventListener("shown.bs.tab", (event) => {
|
||||||
|
this.loadHeaders = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawTab = document.getElementById("nav-raw-tab");
|
||||||
|
rawTab.addEventListener("shown.bs.tab", (event) => {
|
||||||
|
this.srcURI = this.resolve("/api/v1/message/" + this.message.ID + "/raw");
|
||||||
|
this.resizeIFrames();
|
||||||
|
});
|
||||||
|
|
||||||
|
// manually refresh tags
|
||||||
|
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
|
||||||
|
this.availableTags = response.data;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Tags.init("select[multiple]");
|
||||||
|
// delay tag change detection to allow Tags to load
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.canSaveTags = true;
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
isHTMLTabSelected() {
|
||||||
|
this.showMobileButtons = this.$refs.navhtml && this.$refs.navhtml.classList.contains("active");
|
||||||
|
},
|
||||||
|
|
||||||
|
renderUI() {
|
||||||
|
// activate the first non-disabled tab
|
||||||
|
document.querySelector("#nav-tab button:not([disabled])").click();
|
||||||
|
document.activeElement.blur(); // blur focus
|
||||||
|
document.getElementById("message-view").scrollTop = 0;
|
||||||
|
|
||||||
|
this.isHTMLTabSelected();
|
||||||
|
|
||||||
|
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
|
||||||
|
listObj.addEventListener("shown.bs.tab", (event) => {
|
||||||
|
this.isHTMLTabSelected();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
|
[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
|
||||||
|
|
||||||
|
// delay 0.5s until vue has rendered the iframe content
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const p = document.getElementById("preview-html");
|
||||||
|
if (p && typeof p.contentWindow.document.body === "object") {
|
||||||
|
try {
|
||||||
|
// make links open in new window
|
||||||
|
const anchorEls = p.contentWindow.document.body.querySelectorAll("a");
|
||||||
|
for (let i = 0; i < anchorEls.length; i++) {
|
||||||
|
const anchorEl = anchorEls[i];
|
||||||
|
const href = anchorEl.getAttribute("href");
|
||||||
|
|
||||||
|
if (href && href.match(/^https?:\/\//i)) {
|
||||||
|
anchorEl.setAttribute("target", "_blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
this.resizeIFrames();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// HTML highlighting
|
||||||
|
hljs.highlightAll();
|
||||||
|
},
|
||||||
|
|
||||||
|
resizeIframe(el) {
|
||||||
|
const i = el.target;
|
||||||
|
if (typeof i.contentWindow.document.body.scrollHeight === "number") {
|
||||||
|
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + "px";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resizeIFrames() {
|
||||||
|
if (this.scaleHTMLPreview !== "display") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = document.getElementById("preview-html");
|
||||||
|
if (h) {
|
||||||
|
if (typeof h.contentWindow.document.body.scrollHeight === "number") {
|
||||||
|
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// set the iframe body & text colors based on current theme
|
||||||
|
initRawIframe(el) {
|
||||||
|
const bodyStyles = window.getComputedStyle(document.body, null);
|
||||||
|
const bg = bodyStyles.getPropertyValue("background-color");
|
||||||
|
const txt = bodyStyles.getPropertyValue("color");
|
||||||
|
|
||||||
|
const body = el.target.contentWindow.document.querySelector("body");
|
||||||
|
if (body) {
|
||||||
|
body.style.color = txt;
|
||||||
|
body.style.backgroundColor = bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resizeIframe(el);
|
||||||
|
},
|
||||||
|
|
||||||
|
// this function is unused but kept here to use for debugging
|
||||||
|
debugDOMPurify(removed) {
|
||||||
|
if (!removed.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoreNodes = ["target", "base", "script", "v:shapes"];
|
||||||
|
|
||||||
|
const d = removed.filter((r) => {
|
||||||
|
if (
|
||||||
|
typeof r.attribute !== "undefined" &&
|
||||||
|
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith("xmlns:"))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// inline comments
|
||||||
|
if (typeof r.element !== "undefined" && (r.element.nodeType === 8 || r.element.tagName === "SCRIPT")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (d.length) {
|
||||||
|
console.log(d);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveTags() {
|
||||||
|
const data = {
|
||||||
|
IDs: [this.message.ID],
|
||||||
|
Tags: this.messageTags,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.put(this.resolve("/api/v1/tags"), data, (response) => {
|
||||||
|
window.scrollInPlace = true;
|
||||||
|
this.$emit("loadMessages");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convert plain text to HTML including anchor links
|
||||||
|
textToHTML(s) {
|
||||||
|
let html = s;
|
||||||
|
|
||||||
|
// full links with http(s)
|
||||||
|
const re = /(\b(https?|ftp):\/\/[-\w@:%_+'!.~#?,&//=;]+)/gim;
|
||||||
|
html = html.replace(re, "˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲");
|
||||||
|
|
||||||
|
// plain www links without https?:// prefix
|
||||||
|
const re2 = /(^|[^/])(www\.[\S]+(\b|$))/gim;
|
||||||
|
html = html.replace(re2, "$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲");
|
||||||
|
|
||||||
|
// escape to HTML & convert <>" back
|
||||||
|
html = html
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/˱˱˱/g, "<")
|
||||||
|
.replace(/˲˲˲/g, ">")
|
||||||
|
.replace(/ˠˠˠ/g, '"');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
||||||
|
<div class="row w-100">
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="messageHeaders">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th class="small">From</th>
|
||||||
|
<td class="privacy">
|
||||||
|
<span v-if="message.From">
|
||||||
|
<span v-if="message.From.Name" class="text-spaces">
|
||||||
|
{{ message.From.Name + " " }}
|
||||||
|
</span>
|
||||||
|
<span v-if="message.From.Address" class="small">
|
||||||
|
<<a :href="searchURI(message.From.Address)" class="text-body">
|
||||||
|
{{ message.From.Address }} </a
|
||||||
|
>>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else> [ Unknown ] </span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="message.ListUnsubscribe.Header != ''"
|
||||||
|
class="small ms-3 link"
|
||||||
|
:title="
|
||||||
|
showUnsubscribe
|
||||||
|
? 'Hide unsubscribe information'
|
||||||
|
: 'Show unsubscribe information'
|
||||||
|
"
|
||||||
|
@click="showUnsubscribe = !showUnsubscribe"
|
||||||
|
>
|
||||||
|
Unsubscribe
|
||||||
|
<i
|
||||||
|
class="bi bi bi-info-circle"
|
||||||
|
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"
|
||||||
|
></i>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="small">
|
||||||
|
<th>To</th>
|
||||||
|
<td class="privacy">
|
||||||
|
<template v-if="message.To && message.To.length">
|
||||||
|
<span v-for="(t, i) in message.To" :key="'to_' + i">
|
||||||
|
<template v-if="i > 0">, </template>
|
||||||
|
<span>
|
||||||
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
|
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a
|
||||||
|
>>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||||
|
<th>Cc</th>
|
||||||
|
<td class="privacy">
|
||||||
|
<span v-for="(t, i) in message.Cc" :key="'cc_' + i">
|
||||||
|
<template v-if="i > 0">,</template>
|
||||||
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
|
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||||
|
<th>Bcc</th>
|
||||||
|
<td class="privacy">
|
||||||
|
<span v-for="(t, i) in message.Bcc" :key="'bcc_' + i">
|
||||||
|
<template v-if="i > 0">,</template>
|
||||||
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
|
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||||
|
<th class="text-nowrap">Reply-To</th>
|
||||||
|
<td class="privacy text-body-secondary text-break">
|
||||||
|
<span v-for="(t, i) in message.ReplyTo" :key="'bcc_' + i">
|
||||||
|
<template v-if="i > 0">,</template>
|
||||||
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
|
<<a :href="searchURI(t.Address)" class="text-body-secondary"> {{ t.Address }} </a
|
||||||
|
>>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address"
|
||||||
|
class="small"
|
||||||
|
>
|
||||||
|
<th class="text-nowrap">Return-Path</th>
|
||||||
|
<td class="privacy text-body-secondary text-break">
|
||||||
|
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
||||||
|
{{ message.ReturnPath }} </a
|
||||||
|
>>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="small">Subject</th>
|
||||||
|
<td>
|
||||||
|
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
|
||||||
|
<small v-else class="text-body-secondary">[ no subject ]</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="small">
|
||||||
|
<th class="small">Date</th>
|
||||||
|
<td>
|
||||||
|
{{ messageDate(message.Date) }}
|
||||||
|
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="message.Username" class="small">
|
||||||
|
<th class="small">
|
||||||
|
Username
|
||||||
|
<i
|
||||||
|
class="bi bi-exclamation-circle ms-1"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top"
|
||||||
|
data-bs-custom-class="custom-tooltip"
|
||||||
|
data-bs-title="The SMTP or send API username the client authenticated with"
|
||||||
|
>
|
||||||
|
</i>
|
||||||
|
</th>
|
||||||
|
<td class="small">
|
||||||
|
{{ message.Username }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="small">
|
||||||
|
<th>Tags</th>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
v-model="messageTags"
|
||||||
|
class="form-select small tag-selector"
|
||||||
|
multiple
|
||||||
|
data-full-width="false"
|
||||||
|
data-suggestions-threshold="1"
|
||||||
|
data-allow-new="true"
|
||||||
|
data-clear-end="true"
|
||||||
|
data-allow-clear="true"
|
||||||
|
data-placeholder="Add tags..."
|
||||||
|
data-badge-style="secondary"
|
||||||
|
data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
|
||||||
|
data-separator="|,|"
|
||||||
|
>
|
||||||
|
<option value="">Type a tag...</option>
|
||||||
|
<!-- you need at least one option with the placeholder -->
|
||||||
|
<option v-for="t in availableTags" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="invalid-feedback">Invalid tag name</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
v-if="message.ListUnsubscribe.Header != ''"
|
||||||
|
class="small"
|
||||||
|
:class="showUnsubscribe ? '' : 'd-none'"
|
||||||
|
>
|
||||||
|
<th>Unsubscribe</th>
|
||||||
|
<td>
|
||||||
|
<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2">
|
||||||
|
<template v-for="(u, i) in message.ListUnsubscribe.Links">
|
||||||
|
<template v-if="i > 0">, </template>
|
||||||
|
<{{ u }}>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
v-if="message.ListUnsubscribe.HeaderPost != ''"
|
||||||
|
class="bi bi-info-circle text-success me-2 link"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top"
|
||||||
|
data-bs-custom-class="custom-tooltip"
|
||||||
|
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost"
|
||||||
|
>
|
||||||
|
</i>
|
||||||
|
<i
|
||||||
|
v-if="message.ListUnsubscribe.Errors != ''"
|
||||||
|
class="bi bi-exclamation-circle text-danger link"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top"
|
||||||
|
data-bs-custom-class="custom-tooltip"
|
||||||
|
:data-bs-title="message.ListUnsubscribe.Errors"
|
||||||
|
>
|
||||||
|
</i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(message.Attachments && message.Attachments.length) || (message.Inline && message.Inline.length)"
|
||||||
|
class="col-md-auto d-none d-md-block text-end mt-md-3"
|
||||||
|
>
|
||||||
|
<div class="mt-2 mt-md-0">
|
||||||
|
<template v-if="message.Attachments.length">
|
||||||
|
<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message">
|
||||||
|
Attachment<span v-if="message.Attachments.length > 1">s</span> ({{
|
||||||
|
message.Attachments.length
|
||||||
|
}})
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="message.Inline.length"
|
||||||
|
class="badge rounded-pill text-bg-secondary p-2"
|
||||||
|
title="Inline images in this message"
|
||||||
|
>
|
||||||
|
Inline image<span v-if="message.Inline.length > 1">s</span> ({{ message.Inline.length }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav id="nav-tab" class="nav nav-tabs my-3 d-print-none" role="tablist">
|
||||||
|
<template v-if="message.HTML">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
id="nav-html-tab"
|
||||||
|
ref="navhtml"
|
||||||
|
class="nav-link"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-html"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-html"
|
||||||
|
aria-selected="true"
|
||||||
|
@click="resizeIFrames()"
|
||||||
|
>
|
||||||
|
HTML
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
data-bs-reference="parent"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-html-source"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-html-source"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
HTML Source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="nav-html-source-tab"
|
||||||
|
class="nav-link d-none d-sm-inline"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-html-source"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-html-source"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="nav-plain-text-tab"
|
||||||
|
class="nav-link"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-plain-text"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-plain-text"
|
||||||
|
aria-selected="false"
|
||||||
|
:class="message.HTML == '' ? 'show' : ''"
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="nav-headers-tab"
|
||||||
|
class="nav-link"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-headers"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-headers"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="nav-raw-tab"
|
||||||
|
class="nav-link"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-raw"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-raw"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Raw
|
||||||
|
</button>
|
||||||
|
<div v-show="hasAnyChecksEnabled" class="dropdown d-xl-none">
|
||||||
|
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Checks
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu checks">
|
||||||
|
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||||
|
<button
|
||||||
|
id="nav-html-check-tab"
|
||||||
|
class="dropdown-item"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-html-check"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-html"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
HTML Check
|
||||||
|
<span
|
||||||
|
v-if="htmlScore !== false"
|
||||||
|
class="badge rounded-pill p-1 float-end"
|
||||||
|
:class="htmlScoreColor"
|
||||||
|
>
|
||||||
|
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="mailbox.showLinkCheck">
|
||||||
|
<button
|
||||||
|
id="nav-link-check-tab"
|
||||||
|
class="dropdown-item"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-link-check"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-link-check"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Link Check
|
||||||
|
<span v-if="linkCheckErrors === 0" class="badge rounded-pill bg-success float-end">
|
||||||
|
<small>0</small>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger float-end">
|
||||||
|
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||||
|
<button
|
||||||
|
id="nav-spam-check-tab"
|
||||||
|
class="dropdown-item"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-spam-check"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-html"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Spam Analysis
|
||||||
|
<span
|
||||||
|
v-if="spamScore !== false"
|
||||||
|
class="badge rounded-pill float-end"
|
||||||
|
:class="spamScoreColor"
|
||||||
|
>
|
||||||
|
<small>{{ spamScore }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="mailbox.showHTMLCheck && message.HTML != ''"
|
||||||
|
id="nav-html-check-tab"
|
||||||
|
class="d-none d-xl-inline-block nav-link position-relative"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-html-check"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-html"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
HTML Check
|
||||||
|
<span v-if="htmlScore !== false" class="badge rounded-pill p-1" :class="htmlScoreColor">
|
||||||
|
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mailbox.showLinkCheck"
|
||||||
|
id="nav-link-check-tab"
|
||||||
|
class="d-none d-xl-inline-block nav-link"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-link-check"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-link-check"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Link Check
|
||||||
|
<i v-if="linkCheckErrors === 0" class="bi bi-check-all text-success"></i>
|
||||||
|
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger">
|
||||||
|
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
|
||||||
|
id="nav-spam-check-tab"
|
||||||
|
class="d-none d-xl-inline-block nav-link position-relative"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-spam-check"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="nav-html"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Spam Analysis
|
||||||
|
<span v-if="spamScore !== false" class="badge rounded-pill" :class="spamScoreColor">
|
||||||
|
<small>{{ spamScore }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showMobileButtons" class="d-none d-lg-block ms-auto me-3">
|
||||||
|
<template v-for="(_, key) in responsiveSizes" :key="'responsive_' + key">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:disabled="scaleHTMLPreview == key"
|
||||||
|
:title="'Switch to ' + key + ' view'"
|
||||||
|
@click="scaleHTMLPreview = key"
|
||||||
|
>
|
||||||
|
<i class="bi" :class="'bi-' + key"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="nav-tabContent" class="tab-content mb-5">
|
||||||
|
<div
|
||||||
|
v-if="message.HTML != ''"
|
||||||
|
id="nav-html"
|
||||||
|
class="tab-pane fade show"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="nav-html-tab"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||||
|
<iframe
|
||||||
|
id="preview-html"
|
||||||
|
target-blank=""
|
||||||
|
class="tab-pane d-block"
|
||||||
|
:srcdoc="sanitizedHTML"
|
||||||
|
frameborder="0"
|
||||||
|
style="width: 100%; height: 100%; background: #fff"
|
||||||
|
@load="resizeIframe"
|
||||||
|
>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
<Attachments
|
||||||
|
v-if="allAttachments(message).length"
|
||||||
|
:message="message"
|
||||||
|
:attachments="allAttachments(message)"
|
||||||
|
>
|
||||||
|
</Attachments>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message.HTML"
|
||||||
|
id="nav-html-source"
|
||||||
|
class="tab-pane fade"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="nav-html-source-tab"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="nav-plain-text"
|
||||||
|
class="tab-pane fade"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="nav-plain-text-tab"
|
||||||
|
tabindex="0"
|
||||||
|
:class="message.HTML == '' ? 'show' : ''"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
||||||
|
<!-- -eslint-disable vue/no-v-html -->
|
||||||
|
<Attachments
|
||||||
|
v-if="allAttachments(message).length"
|
||||||
|
:message="message"
|
||||||
|
:attachments="allAttachments(message)"
|
||||||
|
>
|
||||||
|
</Attachments>
|
||||||
|
</div>
|
||||||
|
<div id="nav-headers" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||||
|
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||||
|
</div>
|
||||||
|
<div id="nav-raw" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
||||||
|
<iframe
|
||||||
|
v-if="srcURI"
|
||||||
|
:src="srcURI"
|
||||||
|
frameborder="0"
|
||||||
|
style="width: 100%; height: 300px"
|
||||||
|
@load="initRawIframe"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="nav-html-check"
|
||||||
|
class="tab-pane fade"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="nav-html-check-tab"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<HTMLCheck
|
||||||
|
v-if="mailbox.showHTMLCheck && message.HTML != ''"
|
||||||
|
:message="message"
|
||||||
|
@set-html-score="(n) => (htmlScore = n)"
|
||||||
|
@set-badge-style="(v) => (htmlScoreColor = v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
|
||||||
|
id="nav-spam-check"
|
||||||
|
class="tab-pane fade"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="nav-spam-check-tab"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<SpamAssassin
|
||||||
|
:message="message"
|
||||||
|
@set-spam-score="(n) => (spamScore = n)"
|
||||||
|
@set-badge-style="(v) => (spamScoreColor = v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="mailbox.showLinkCheck"
|
||||||
|
id="nav-link-check"
|
||||||
|
class="tab-pane fade"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="nav-html-check-tab"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<LinkCheck :message="message" @set-link-errors="(n) => (linkCheckErrors = n)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
<script>
|
<script>
|
||||||
import AjaxLoader from '../AjaxLoader.vue'
|
import AjaxLoader from "../AjaxLoader.vue";
|
||||||
import Tags from "bootstrap5-tags"
|
import Tags from "bootstrap5-tags";
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from "../../mixins/CommonMixins";
|
||||||
import { mailbox } from '../../stores/mailbox'
|
import { mailbox } from "../../stores/mailbox";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
},
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ['delete'],
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ["delete"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -21,64 +26,62 @@ export default {
|
|||||||
deleteAfterRelease: false,
|
deleteAfterRelease: false,
|
||||||
mailbox,
|
mailbox,
|
||||||
allAddresses: [],
|
allAddresses: [],
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [commonMixins],
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
let a = []
|
const a = [];
|
||||||
for (let i in this.message.To) {
|
for (const i in this.message.To) {
|
||||||
a.push(this.message.To[i].Address)
|
a.push(this.message.To[i].Address);
|
||||||
}
|
}
|
||||||
for (let i in this.message.Cc) {
|
for (const i in this.message.Cc) {
|
||||||
a.push(this.message.Cc[i].Address)
|
a.push(this.message.Cc[i].Address);
|
||||||
}
|
}
|
||||||
for (let i in this.message.Bcc) {
|
for (const i in this.message.Bcc) {
|
||||||
a.push(this.message.Bcc[i].Address)
|
a.push(this.message.Bcc[i].Address);
|
||||||
}
|
}
|
||||||
|
|
||||||
// include only unique email addresses, regardless of casing
|
// 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: {
|
methods: {
|
||||||
// triggered manually after modal is shown
|
// triggered manually after modal is shown
|
||||||
initTags() {
|
initTags() {
|
||||||
Tags.init("select[multiple]")
|
Tags.init("select[multiple]");
|
||||||
},
|
},
|
||||||
|
|
||||||
releaseMessage() {
|
releaseMessage() {
|
||||||
// set timeout to allow for user clicking send before the tag filter has applied the tag
|
// set timeout to allow for user clicking send before the tag filter has applied the tag
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (!this.addresses.length) {
|
if (!this.addresses.length) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = {
|
const data = {
|
||||||
To: this.addresses
|
To: this.addresses,
|
||||||
}
|
};
|
||||||
|
|
||||||
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => {
|
this.post(this.resolve("/api/v1/message/" + this.message.ID + "/release"), data, (response) => {
|
||||||
this.modal("ReleaseModal").hide()
|
this.modal("ReleaseModal").hide();
|
||||||
if (this.deleteAfterRelease) {
|
if (this.deleteAfterRelease) {
|
||||||
this.$emit('delete')
|
this.$emit("delete");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, 100)
|
}, 100);
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
<div id="ReleaseModal" class="modal fade" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-xl" v-if="message">
|
<div v-if="message" class="modal-dialog modal-xl">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -86,32 +89,55 @@ export default {
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
|
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text"
|
<input
|
||||||
aria-label="From address" readonly class="form-control-plaintext"
|
v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''"
|
||||||
:value="mailbox.uiConfig.MessageRelay.OverrideFrom">
|
type="text"
|
||||||
<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext"
|
aria-label="From address"
|
||||||
:value="message.From ? message.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>
|
</div>
|
||||||
<div class="row">
|
<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">
|
<div class="col-sm-10">
|
||||||
<input type="text" aria-label="Subject" readonly class="form-control-plaintext"
|
<input
|
||||||
:value="message.Subject">
|
type="text"
|
||||||
|
aria-label="Subject"
|
||||||
|
readonly
|
||||||
|
class="form-control-plaintext"
|
||||||
|
:value="message.Subject"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
|
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
|
<select
|
||||||
data-clear-end="true" data-allow-clear="true"
|
v-model="addresses"
|
||||||
data-placeholder="Enter email addresses..." data-add-on-blur="true"
|
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-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-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>
|
<option value="">Enter email addresses...</option>
|
||||||
<!-- you need at least one option with the placeholder -->
|
<!-- 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>
|
</select>
|
||||||
<div class="invalid-feedback">Invalid email address</div>
|
<div class="invalid-feedback">Invalid email address</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,8 +145,12 @@ export default {
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-sm-10 offset-sm-2">
|
<div class="col-sm-10 offset-sm-2">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" v-model="deleteAfterRelease"
|
<input
|
||||||
id="DeleteAfterRelease">
|
id="DeleteAfterRelease"
|
||||||
|
v-model="deleteAfterRelease"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
<label class="form-check-label" for="DeleteAfterRelease">
|
<label class="form-check-label" for="DeleteAfterRelease">
|
||||||
Delete the message after release
|
Delete the message after release
|
||||||
</label>
|
</label>
|
||||||
@@ -145,7 +175,8 @@ export default {
|
|||||||
</li>
|
</li>
|
||||||
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
|
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
|
||||||
The <code>From</code> email address has been overridden by the relay configuration to
|
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>
|
||||||
<li class="form-text">
|
<li class="form-text">
|
||||||
SMTP delivery failures will bounce back to
|
SMTP delivery failures will bounce back to
|
||||||
@@ -155,14 +186,16 @@ export default {
|
|||||||
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
|
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
|
||||||
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
|
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
|
||||||
</code>
|
</code>
|
||||||
<code v-else>{{ message.ReturnPath }}</code>.
|
<code v-else>{{ message.ReturnPath }}</code
|
||||||
|
>.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
|
<button type="button" class="btn btn-primary" :disabled="!addresses.length" @click="releaseMessage">
|
||||||
v-on:click="releaseMessage">Release</button>
|
Release
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
165
server/ui-src/components/message/MessageScreenshot.vue
Normal file
165
server/ui-src/components/message/MessageScreenshot.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script>
|
||||||
|
import AjaxLoader from "../AjaxLoader.vue";
|
||||||
|
import CommonMixins from "../../mixins/CommonMixins";
|
||||||
|
import { domToPng } from "modern-screenshot";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AjaxLoader,
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
html: false,
|
||||||
|
loading: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
initScreenshot() {
|
||||||
|
this.loading = 1;
|
||||||
|
// remove base tag, if set
|
||||||
|
let h = this.message.HTML.replace(/<base .*>/im, "");
|
||||||
|
const proxy = this.resolve("/proxy");
|
||||||
|
|
||||||
|
// Outlook hacks - else screenshot returns blank image
|
||||||
|
h = h.replace(/<html [^>]+>/gim, "<html>"); // remove html attributes
|
||||||
|
h = h.replace(/<o:p><\/o:p>/gm, ""); // remove empty `<o:p></o:p>` tags
|
||||||
|
h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>`
|
||||||
|
h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>`
|
||||||
|
|
||||||
|
// update any inline `url(...)` absolute links
|
||||||
|
const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim;
|
||||||
|
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
|
||||||
|
if (typeof p2 === "string") {
|
||||||
|
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`;
|
||||||
|
}
|
||||||
|
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// create temporary document to manipulate
|
||||||
|
const doc = document.implementation.createHTMLDocument();
|
||||||
|
doc.open();
|
||||||
|
doc.write(h);
|
||||||
|
doc.close();
|
||||||
|
|
||||||
|
// remove any <script> tags
|
||||||
|
const scripts = doc.getElementsByTagName("script");
|
||||||
|
for (const i of scripts) {
|
||||||
|
i.parentNode.removeChild(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace stylesheet links with proxy links
|
||||||
|
const stylesheets = doc.getElementsByTagName("link");
|
||||||
|
for (const i of stylesheets) {
|
||||||
|
const src = i.getAttribute("href");
|
||||||
|
|
||||||
|
if (
|
||||||
|
src &&
|
||||||
|
src.match(/^https?:\/\//i) &&
|
||||||
|
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||||
|
) {
|
||||||
|
i.setAttribute("href", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace images with proxy links
|
||||||
|
const images = doc.getElementsByTagName("img");
|
||||||
|
for (const i of images) {
|
||||||
|
const src = i.getAttribute("src");
|
||||||
|
if (
|
||||||
|
src &&
|
||||||
|
src.match(/^https?:\/\//i) &&
|
||||||
|
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||||
|
) {
|
||||||
|
i.setAttribute("src", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace background="" attributes with proxy links
|
||||||
|
const backgrounds = doc.querySelectorAll("[background]");
|
||||||
|
for (const i of backgrounds) {
|
||||||
|
const src = i.getAttribute("background");
|
||||||
|
|
||||||
|
if (
|
||||||
|
src &&
|
||||||
|
src.match(/^https?:\/\//i) &&
|
||||||
|
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||||
|
) {
|
||||||
|
// replace with proxy link
|
||||||
|
i.setAttribute("background", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set html with manipulated document content
|
||||||
|
this.html = new XMLSerializer().serializeToString(doc);
|
||||||
|
},
|
||||||
|
|
||||||
|
// HTML decode function
|
||||||
|
decodeEntities(s) {
|
||||||
|
const e = document.createElement("div");
|
||||||
|
e.innerHTML = s;
|
||||||
|
const str = e.textContent;
|
||||||
|
e.textContent = "";
|
||||||
|
return str;
|
||||||
|
},
|
||||||
|
|
||||||
|
doScreenshot() {
|
||||||
|
let width = document.getElementById("message-view").getBoundingClientRect().width;
|
||||||
|
|
||||||
|
const prev = document.getElementById("preview-html");
|
||||||
|
if (prev && prev.getBoundingClientRect().width) {
|
||||||
|
width = prev.getBoundingClientRect().width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width < 300) {
|
||||||
|
width = 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = document.getElementById("screenshot-html");
|
||||||
|
|
||||||
|
// set the iframe width
|
||||||
|
i.style.width = width + "px";
|
||||||
|
|
||||||
|
const body = i.contentWindow.document.querySelector("body");
|
||||||
|
|
||||||
|
// take screenshot of iframe
|
||||||
|
domToPng(body, {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
height: i.contentWindow.document.body.scrollHeight + 20,
|
||||||
|
width,
|
||||||
|
}).then((dataUrl) => {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = this.message.ID + ".png";
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
this.loading = 0;
|
||||||
|
this.html = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<iframe
|
||||||
|
v-if="html"
|
||||||
|
id="screenshot-html"
|
||||||
|
:srcdoc="html"
|
||||||
|
frameborder="0"
|
||||||
|
style="position: absolute; margin-left: -100000px"
|
||||||
|
@load="doScreenshot"
|
||||||
|
>
|
||||||
|
</iframe>
|
||||||
|
|
||||||
|
<AjaxLoader :loading="loading" />
|
||||||
|
</template>
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<script>
|
|
||||||
import AjaxLoader from '../AjaxLoader.vue'
|
|
||||||
import CommonMixins from '../../mixins/CommonMixins'
|
|
||||||
import { domToPng } from 'modern-screenshot'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
components: {
|
|
||||||
AjaxLoader,
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
html: false,
|
|
||||||
loading: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
initScreenshot() {
|
|
||||||
this.loading = 1
|
|
||||||
// remove base tag, if set
|
|
||||||
let h = this.message.HTML.replace(/<base .*>/mi, '')
|
|
||||||
let proxy = this.resolve('/proxy')
|
|
||||||
|
|
||||||
// Outlook hacks - else screenshot returns blank image
|
|
||||||
h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes
|
|
||||||
h = h.replace(/<o:p><\/o:p>/mg, '') // remove empty `<o:p></o:p>` tags
|
|
||||||
h = h.replace(/<o:/mg, '<') // replace `<o:p>` tags with `<p>`
|
|
||||||
h = h.replace(/<\/o:/mg, '</') // replace `</o:p>` tags with `</p>`
|
|
||||||
|
|
||||||
// update any inline `url(...)` absolute links
|
|
||||||
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
|
|
||||||
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
|
|
||||||
if (typeof p2 === 'string') {
|
|
||||||
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
|
|
||||||
}
|
|
||||||
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
|
|
||||||
})
|
|
||||||
|
|
||||||
// create temporary document to manipulate
|
|
||||||
let doc = document.implementation.createHTMLDocument();
|
|
||||||
doc.open()
|
|
||||||
doc.write(h)
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
// remove any <script> tags
|
|
||||||
let scripts = doc.getElementsByTagName('script')
|
|
||||||
for (let i of scripts) {
|
|
||||||
i.parentNode.removeChild(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace stylesheet links with proxy links
|
|
||||||
let stylesheets = doc.getElementsByTagName('link')
|
|
||||||
for (let i of stylesheets) {
|
|
||||||
let src = i.getAttribute('href')
|
|
||||||
|
|
||||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
|
||||||
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace images with proxy links
|
|
||||||
let images = doc.getElementsByTagName('img')
|
|
||||||
for (let i of images) {
|
|
||||||
let src = i.getAttribute('src')
|
|
||||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
|
||||||
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace background="" attributes with proxy links
|
|
||||||
let backgrounds = doc.querySelectorAll("[background]")
|
|
||||||
for (let i of backgrounds) {
|
|
||||||
let src = i.getAttribute('background')
|
|
||||||
|
|
||||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
|
||||||
// replace with proxy link
|
|
||||||
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// set html with manipulated document content
|
|
||||||
this.html = new XMLSerializer().serializeToString(doc)
|
|
||||||
},
|
|
||||||
|
|
||||||
// HTML decode function
|
|
||||||
decodeEntities(s) {
|
|
||||||
let e = document.createElement('div')
|
|
||||||
e.innerHTML = s
|
|
||||||
let str = e.textContent
|
|
||||||
e.textContent = ''
|
|
||||||
return str
|
|
||||||
},
|
|
||||||
|
|
||||||
doScreenshot() {
|
|
||||||
let width = document.getElementById('message-view').getBoundingClientRect().width
|
|
||||||
|
|
||||||
let prev = document.getElementById('preview-html')
|
|
||||||
if (prev && prev.getBoundingClientRect().width) {
|
|
||||||
width = prev.getBoundingClientRect().width
|
|
||||||
}
|
|
||||||
|
|
||||||
if (width < 300) {
|
|
||||||
width = 300
|
|
||||||
}
|
|
||||||
|
|
||||||
const i = document.getElementById('screenshot-html')
|
|
||||||
|
|
||||||
// set the iframe width
|
|
||||||
i.style.width = width + 'px'
|
|
||||||
|
|
||||||
let body = i.contentWindow.document.querySelector('body')
|
|
||||||
|
|
||||||
// take screenshot of iframe
|
|
||||||
domToPng(body, {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
height: i.contentWindow.document.body.scrollHeight + 20,
|
|
||||||
width: width,
|
|
||||||
}).then(dataUrl => {
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.download = this.message.ID + '.png'
|
|
||||||
link.href = dataUrl
|
|
||||||
link.click()
|
|
||||||
this.loading = 0
|
|
||||||
this.html = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<iframe v-if="html" :srcdoc="html" v-on:load="doScreenshot" frameborder="0" id="screenshot-html"
|
|
||||||
style="position: absolute; margin-left: -100000px;">
|
|
||||||
</iframe>
|
|
||||||
|
|
||||||
<AjaxLoader :loading="loading" />
|
|
||||||
</template>
|
|
||||||
@@ -1,52 +1,85 @@
|
|||||||
<script>
|
<script>
|
||||||
import { VcDonut } from 'vue-css-donut-chart'
|
import { VcDonut } from "vue-css-donut-chart";
|
||||||
import axios from 'axios'
|
import axios from "axios";
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from "../../mixins/CommonMixins";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
},
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
VcDonut,
|
VcDonut,
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ["setSpamScore", "setBadgeStyle"],
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ["setSpamScore", "setBadgeStyle"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
error: false,
|
error: false,
|
||||||
check: false,
|
check: false,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
computed: {
|
||||||
this.doCheck()
|
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: {
|
watch: {
|
||||||
message: {
|
message: {
|
||||||
handler() {
|
handler() {
|
||||||
this.$emit('setSpamScore', false)
|
this.$emit("setSpamScore", false);
|
||||||
this.doCheck()
|
this.doCheck();
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.doCheck();
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
doCheck() {
|
doCheck() {
|
||||||
this.check = false
|
this.check = false;
|
||||||
|
|
||||||
// ignore any error, do not show loader
|
// 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) => {
|
.then((result) => {
|
||||||
this.check = result.data
|
this.check = result.data;
|
||||||
this.error = false
|
this.error = false;
|
||||||
this.setIcons()
|
this.setIcons();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// handle error
|
// handle error
|
||||||
@@ -54,80 +87,50 @@ export default {
|
|||||||
// The request was made and the server responded with a status code
|
// The request was made and the server responded with a status code
|
||||||
// that falls out of the range of 2xx
|
// that falls out of the range of 2xx
|
||||||
if (error.response.data.Error) {
|
if (error.response.data.Error) {
|
||||||
this.error = error.response.data.Error
|
this.error = error.response.data.Error;
|
||||||
} else {
|
} else {
|
||||||
this.error = error.response.data
|
this.error = error.response.data;
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
// The request was made but no response was received
|
// The request was made but no response was received
|
||||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||||
// http.ClientRequest in node.js
|
// 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 {
|
} else {
|
||||||
// Something happened in setting up the request that triggered an Error
|
// Something happened in setting up the request that triggered an Error
|
||||||
this.error = error.message
|
this.error = error.message;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
badgeStyle(ignorePadding = false) {
|
badgeStyle(ignorePadding = false) {
|
||||||
let badgeStyle = 'bg-success'
|
let badgeStyle = "bg-success";
|
||||||
if (this.check.Error) {
|
if (this.check.Error) {
|
||||||
badgeStyle = 'bg-warning text-primary'
|
badgeStyle = "bg-warning text-primary";
|
||||||
}
|
} else if (this.check.IsSpam) {
|
||||||
else if (this.check.IsSpam) {
|
badgeStyle = "bg-danger";
|
||||||
badgeStyle = 'bg-danger'
|
|
||||||
} else if (this.check.Score >= 4) {
|
} else if (this.check.Score >= 4) {
|
||||||
badgeStyle = 'bg-warning text-primary'
|
badgeStyle = "bg-warning text-primary";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ignorePadding && String(this.check.Score).includes('.')) {
|
if (!ignorePadding && String(this.check.Score).includes(".")) {
|
||||||
badgeStyle += " p-1"
|
badgeStyle += " p-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
return badgeStyle
|
return badgeStyle;
|
||||||
},
|
},
|
||||||
|
|
||||||
setIcons() {
|
setIcons() {
|
||||||
let score = this.check.Score
|
let score = this.check.Score;
|
||||||
if (this.check.Error && this.check.Error != '') {
|
if (this.check.Error && this.check.Error !== "") {
|
||||||
score = '!'
|
score = "!";
|
||||||
}
|
}
|
||||||
let badgeStyle = this.badgeStyle()
|
const badgeStyle = this.badgeStyle();
|
||||||
this.$emit('setBadgeStyle', badgeStyle)
|
this.$emit("setBadgeStyle", badgeStyle);
|
||||||
this.$emit('setSpamScore', score)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,10 +148,10 @@ export default {
|
|||||||
|
|
||||||
<template v-if="error || check.Error != ''">
|
<template v-if="error || check.Error != ''">
|
||||||
<p>Your message could not be checked</p>
|
<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 }}
|
{{ error }}
|
||||||
</div>
|
</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 }}
|
There was an error contacting the configured SpamAssassin server: {{ check.Error }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -156,11 +159,18 @@ export default {
|
|||||||
<template v-else-if="check">
|
<template v-else-if="check">
|
||||||
<div class="row w-100 mt-5">
|
<div class="row w-100 mt-5">
|
||||||
<div class="col-xl-5 mb-2">
|
<div class="col-xl-5 mb-2">
|
||||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
<vc-donut
|
||||||
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
|
:sections="graphSections"
|
||||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
background="var(--bs-body-bg)"
|
||||||
{{ check.Score }} / 5
|
:size="230"
|
||||||
</h2>
|
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">
|
<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-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>
|
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
|
||||||
@@ -180,7 +190,7 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="col-2 col-lg-1">
|
||||||
{{ r.Score }}
|
{{ r.Score }}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,25 +205,39 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel"
|
<div
|
||||||
aria-hidden="true">
|
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-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="accordion" id="SpamAnalysisAboutAccordion">
|
<div id="SpamAnalysisAboutAccordion" class="accordion">
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
class="accordion-button collapsed"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col1"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="col1"
|
||||||
|
>
|
||||||
What is Spam Analysis?
|
What is Spam Analysis?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col1" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
id="col1"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
Mailpit integrates with SpamAssassin to provide you with some insight into the
|
Mailpit integrates with SpamAssassin to provide you with some insight into the
|
||||||
@@ -226,13 +250,22 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
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?
|
How does the point system work?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col2" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
id="col2"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
|
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
|
||||||
@@ -248,18 +281,27 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
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...
|
But I don't agree with the results...
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col3" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
id="col3"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
Mailpit does not manipulate the results nor determine the "spamminess" of
|
Mailpit does not manipulate the results nor determine the "spamminess" of your
|
||||||
your message. The result is what SpamAssassin returns, and it entirely
|
message. The result is what SpamAssassin returns, and it entirely dependent on
|
||||||
dependent on how SpamAssassin is set up and optionally trained.
|
how SpamAssassin is set up and optionally trained.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This tool is simply provided as an aid to assist you. If you are running your
|
This tool is simply provided as an aid to assist you. If you are running your
|
||||||
@@ -271,20 +313,31 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button
|
||||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
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?
|
Where can I find more information about the triggered rules?
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="col4" class="accordion-collapse collapse"
|
<div
|
||||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
id="col4"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||||
|
>
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p>
|
<p>
|
||||||
Unfortunately the current <a href="https://spamassassin.apache.org/"
|
Unfortunately the current
|
||||||
target="_blank">SpamAssassin website</a> no longer contains any relative
|
<a href="https://spamassassin.apache.org/" target="_blank"
|
||||||
documentation about these, most likely because the rules come from different
|
>SpamAssassin website</a
|
||||||
locations and change often. You will need to search the internet for these
|
>
|
||||||
yourself.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import axios from 'axios'
|
import axios from "axios";
|
||||||
import dayjs from 'dayjs'
|
import dayjs from "dayjs";
|
||||||
import ColorHash from 'color-hash'
|
import ColorHash from "color-hash";
|
||||||
import { Modal, Offcanvas } from 'bootstrap'
|
import { Modal, Offcanvas } from "bootstrap";
|
||||||
import { limitOptions } from "../stores/pagination";
|
import { limitOptions } from "../stores/pagination";
|
||||||
|
|
||||||
// BootstrapElement is used to return a fake Bootstrap element
|
// BootstrapElement is used to return a fake Bootstrap element
|
||||||
// if the ID returns nothing to prevent errors.
|
// if the ID returns nothing to prevent errors.
|
||||||
class BootstrapElement {
|
class BootstrapElement {
|
||||||
constructor() { }
|
hide() {}
|
||||||
hide() { }
|
show() {}
|
||||||
show() { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the color hash generator lightness and hue to ensure darker colors
|
// Set up the color hash generator lightness and hue to ensure darker colors
|
||||||
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] })
|
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
|
||||||
|
|
||||||
/* Common mixin functions used in apps */
|
/* Common mixin functions used in apps */
|
||||||
export default {
|
export default {
|
||||||
@@ -21,89 +20,89 @@ export default {
|
|||||||
return {
|
return {
|
||||||
loading: 0,
|
loading: 0,
|
||||||
tagColorCache: {},
|
tagColorCache: {},
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
resolve(u) {
|
resolve(u) {
|
||||||
return this.$router.resolve(u).href
|
return this.$router.resolve(u).href;
|
||||||
},
|
},
|
||||||
|
|
||||||
searchURI(s) {
|
searchURI(s) {
|
||||||
return this.resolve('/search') + '?q=' + encodeURIComponent(s)
|
return this.resolve("/search") + "?q=" + encodeURIComponent(s);
|
||||||
},
|
},
|
||||||
|
|
||||||
getFileSize(bytes) {
|
getFileSize(bytes) {
|
||||||
if (bytes == 0) {
|
if (bytes === 0) {
|
||||||
return '0B'
|
return "0B";
|
||||||
}
|
}
|
||||||
var i = Math.floor(Math.log(bytes) / Math.log(1024))
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
|
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||||
},
|
},
|
||||||
|
|
||||||
formatNumber(nr) {
|
formatNumber(nr) {
|
||||||
return new Intl.NumberFormat().format(nr)
|
return new Intl.NumberFormat().format(nr);
|
||||||
},
|
},
|
||||||
|
|
||||||
messageDate(d) {
|
messageDate(d) {
|
||||||
return dayjs(d).format('ddd, D MMM YYYY, h:mm a')
|
return dayjs(d).format("ddd, D MMM YYYY, h:mm a");
|
||||||
},
|
},
|
||||||
|
|
||||||
secondsToRelative(d) {
|
secondsToRelative(d) {
|
||||||
return dayjs().subtract(d, 'seconds').fromNow()
|
return dayjs().subtract(d, "seconds").fromNow();
|
||||||
},
|
},
|
||||||
|
|
||||||
tagEncodeURI(tag) {
|
tagEncodeURI(tag) {
|
||||||
if (tag.match(/ /)) {
|
if (tag.match(/ /)) {
|
||||||
tag = `"${tag}"`
|
tag = `"${tag}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodeURIComponent(`tag:${tag}`)
|
return encodeURIComponent(`tag:${tag}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSearch() {
|
getSearch() {
|
||||||
if (!window.location.search) {
|
if (!window.location.search) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const q = urlParams.get('q')?.trim()
|
const q = urlParams.get("q")?.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return q
|
return q;
|
||||||
},
|
},
|
||||||
|
|
||||||
getPaginationParams() {
|
getPaginationParams() {
|
||||||
if (!window.location.search) {
|
if (!window.location.search) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const start = parseInt(urlParams.get('start')?.trim(), 10)
|
const start = parseInt(urlParams.get("start")?.trim(), 10);
|
||||||
const limit = parseInt(urlParams.get('limit')?.trim(), 10)
|
const limit = parseInt(urlParams.get("limit")?.trim(), 10);
|
||||||
return {
|
return {
|
||||||
start: Number.isInteger(start) && start >= 0 ? start : null,
|
start: Number.isInteger(start) && start >= 0 ? start : null,
|
||||||
limit: limitOptions.includes(limit) ? limit : null,
|
limit: limitOptions.includes(limit) ? limit : null,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// generic modal get/set function
|
// generic modal get/set function
|
||||||
modal(id) {
|
modal(id) {
|
||||||
const e = document.getElementById(id)
|
const e = document.getElementById(id);
|
||||||
if (e) {
|
if (e) {
|
||||||
return Modal.getOrCreateInstance(e)
|
return Modal.getOrCreateInstance(e);
|
||||||
}
|
}
|
||||||
// in case there are open/close actions
|
// in case there are open/close actions
|
||||||
return new BootstrapElement()
|
return new BootstrapElement();
|
||||||
},
|
},
|
||||||
|
|
||||||
// close mobile navigation
|
// close mobile navigation
|
||||||
hideNav() {
|
hideNav() {
|
||||||
const e = document.getElementById('offcanvas')
|
const e = document.getElementById("offcanvas");
|
||||||
if (e) {
|
if (e) {
|
||||||
Offcanvas.getOrCreateInstance(e).hide()
|
Offcanvas.getOrCreateInstance(e).hide();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -117,23 +116,24 @@ export default {
|
|||||||
*/
|
*/
|
||||||
get(url, values, callback, errorCallback, hideLoader) {
|
get(url, values, callback, errorCallback, hideLoader) {
|
||||||
if (!hideLoader) {
|
if (!hideLoader) {
|
||||||
this.loading++
|
this.loading++;
|
||||||
}
|
}
|
||||||
axios.get(url, { params: values })
|
axios
|
||||||
|
.get(url, { params: values })
|
||||||
.then(callback)
|
.then(callback)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (typeof errorCallback == 'function') {
|
if (typeof errorCallback === "function") {
|
||||||
return errorCallback(err)
|
return errorCallback(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handleError(err)
|
this.handleError(err);
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// always executed
|
// always executed
|
||||||
if (!hideLoader && this.loading > 0) {
|
if (!hideLoader && this.loading > 0) {
|
||||||
this.loading--
|
this.loading--;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,16 +144,17 @@ export default {
|
|||||||
* @params function callback function
|
* @params function callback function
|
||||||
*/
|
*/
|
||||||
post(url, data, callback) {
|
post(url, data, callback) {
|
||||||
this.loading++
|
this.loading++;
|
||||||
axios.post(url, data)
|
axios
|
||||||
|
.post(url, data)
|
||||||
.then(callback)
|
.then(callback)
|
||||||
.catch(this.handleError)
|
.catch(this.handleError)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// always executed
|
// always executed
|
||||||
if (this.loading > 0) {
|
if (this.loading > 0) {
|
||||||
this.loading--
|
this.loading--;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,16 +165,17 @@ export default {
|
|||||||
* @params function callback function
|
* @params function callback function
|
||||||
*/
|
*/
|
||||||
delete(url, data, callback) {
|
delete(url, data, callback) {
|
||||||
this.loading++
|
this.loading++;
|
||||||
axios.delete(url, { data: data })
|
axios
|
||||||
|
.delete(url, { data })
|
||||||
.then(callback)
|
.then(callback)
|
||||||
.catch(this.handleError)
|
.catch(this.handleError)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// always executed
|
// always executed
|
||||||
if (this.loading > 0) {
|
if (this.loading > 0) {
|
||||||
this.loading--
|
this.loading--;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,16 +186,17 @@ export default {
|
|||||||
* @params function callback function
|
* @params function callback function
|
||||||
*/
|
*/
|
||||||
put(url, data, callback) {
|
put(url, data, callback) {
|
||||||
this.loading++
|
this.loading++;
|
||||||
axios.put(url, data)
|
axios
|
||||||
|
.put(url, data)
|
||||||
.then(callback)
|
.then(callback)
|
||||||
.catch(this.handleError)
|
.catch(this.handleError)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// always executed
|
// always executed
|
||||||
if (this.loading > 0) {
|
if (this.loading > 0) {
|
||||||
this.loading--
|
this.loading--;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ajax error message
|
// Ajax error message
|
||||||
@@ -203,87 +206,87 @@ export default {
|
|||||||
// The request was made and the server responded with a status code
|
// The request was made and the server responded with a status code
|
||||||
// that falls out of the range of 2xx
|
// that falls out of the range of 2xx
|
||||||
if (error.response.data.Error) {
|
if (error.response.data.Error) {
|
||||||
alert(error.response.data.Error)
|
alert(error.response.data.Error);
|
||||||
} else {
|
} else {
|
||||||
alert(error.response.data)
|
alert(error.response.data);
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
// The request was made but no response was received
|
// The request was made but no response was received
|
||||||
alert('Error sending data to the server. Please try again.')
|
alert("Error sending data to the server. Please try again.");
|
||||||
} else {
|
} else {
|
||||||
// Something happened in setting up the request that triggered an Error
|
// Something happened in setting up the request that triggered an Error
|
||||||
alert(error.message)
|
alert(error.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
allAttachments(message) {
|
allAttachments(message) {
|
||||||
let a = []
|
const a = [];
|
||||||
for (let i in message.Attachments) {
|
for (const i in message.Attachments) {
|
||||||
a.push(message.Attachments[i])
|
a.push(message.Attachments[i]);
|
||||||
}
|
}
|
||||||
for (let i in message.OtherParts) {
|
for (const i in message.OtherParts) {
|
||||||
a.push(message.OtherParts[i])
|
a.push(message.OtherParts[i]);
|
||||||
}
|
}
|
||||||
for (let i in message.Inline) {
|
for (const i in message.Inline) {
|
||||||
a.push(message.Inline[i])
|
a.push(message.Inline[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.length ? a : false
|
return a.length ? a : false;
|
||||||
},
|
},
|
||||||
|
|
||||||
isImage(a) {
|
isImage(a) {
|
||||||
return a.ContentType.match(/^image\//)
|
return a.ContentType.match(/^image\//);
|
||||||
},
|
},
|
||||||
|
|
||||||
attachmentIcon(a) {
|
attachmentIcon(a) {
|
||||||
let ext = a.FileName.split('.').pop().toLowerCase()
|
const ext = a.FileName.split(".").pop().toLowerCase();
|
||||||
|
|
||||||
if (a.ContentType.match(/^image\//)) {
|
if (a.ContentType.match(/^image\//)) {
|
||||||
return 'bi-file-image-fill'
|
return "bi-file-image-fill";
|
||||||
}
|
}
|
||||||
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') {
|
if (a.ContentType.match(/\/pdf$/) || ext === "pdf") {
|
||||||
return 'bi-file-pdf-fill'
|
return "bi-file-pdf-fill";
|
||||||
}
|
}
|
||||||
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
|
if (["doc", "docx", "odt", "rtf"].includes(ext)) {
|
||||||
return 'bi-file-word-fill'
|
return "bi-file-word-fill";
|
||||||
}
|
}
|
||||||
if (['xls', 'xlsx', 'ods'].includes(ext)) {
|
if (["xls", "xlsx", "ods"].includes(ext)) {
|
||||||
return 'bi-file-spreadsheet-fill'
|
return "bi-file-spreadsheet-fill";
|
||||||
}
|
}
|
||||||
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
|
if (["ppt", "pptx", "key", "ppt", "odp"].includes(ext)) {
|
||||||
return 'bi-file-slides-fill'
|
return "bi-file-slides-fill";
|
||||||
}
|
}
|
||||||
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
|
if (["zip", "tar", "rar", "bz2", "gz", "xz"].includes(ext)) {
|
||||||
return 'bi-file-zip-fill'
|
return "bi-file-zip-fill";
|
||||||
}
|
}
|
||||||
if (['ics'].includes(ext)) {
|
if (["ics"].includes(ext)) {
|
||||||
return 'bi-calendar-event'
|
return "bi-calendar-event";
|
||||||
}
|
}
|
||||||
if (a.ContentType.match(/^audio\//)) {
|
if (a.ContentType.match(/^audio\//)) {
|
||||||
return 'bi-file-music-fill'
|
return "bi-file-music-fill";
|
||||||
}
|
}
|
||||||
if (a.ContentType.match(/^video\//)) {
|
if (a.ContentType.match(/^video\//)) {
|
||||||
return 'bi-file-play-fill'
|
return "bi-file-play-fill";
|
||||||
}
|
}
|
||||||
if (a.ContentType.match(/\/calendar$/)) {
|
if (a.ContentType.match(/\/calendar$/)) {
|
||||||
return 'bi-file-check-fill'
|
return "bi-file-check-fill";
|
||||||
}
|
}
|
||||||
if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) {
|
if (a.ContentType.match(/^text\//) || ["txt", "sh", "log"].includes(ext)) {
|
||||||
return 'bi-file-text-fill'
|
return "bi-file-text-fill";
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'bi-file-arrow-down-fill'
|
return "bi-file-arrow-down-fill";
|
||||||
},
|
},
|
||||||
|
|
||||||
// Returns a hex color based on a string.
|
// Returns a hex color based on a string.
|
||||||
// Values are stored in an array for faster lookup / processing.
|
// Values are stored in an array for faster lookup / processing.
|
||||||
colorHash(s) {
|
colorHash(s) {
|
||||||
if (this.tagColorCache[s] != undefined) {
|
if (this.tagColorCache[s] !== undefined) {
|
||||||
return this.tagColorCache[s]
|
return this.tagColorCache[s];
|
||||||
}
|
}
|
||||||
this.tagColorCache[s] = colorHash.hex(s)
|
this.tagColorCache[s] = colorHash.hex(s);
|
||||||
|
|
||||||
return this.tagColorCache[s]
|
return this.tagColorCache[s];
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import CommonMixins from './CommonMixins.js'
|
import CommonMixins from "./CommonMixins.js";
|
||||||
import { mailbox } from '../stores/mailbox.js'
|
import { mailbox } from "../stores/mailbox.js";
|
||||||
import { pagination } from '../stores/pagination.js'
|
import { pagination } from "../stores/pagination.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
mixins: [CommonMixins],
|
||||||
@@ -10,88 +10,86 @@ export default {
|
|||||||
apiURI: false,
|
apiURI: false,
|
||||||
pagination,
|
pagination,
|
||||||
mailbox,
|
mailbox,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
'mailbox.refresh': function (v) {
|
"mailbox.refresh": function (v) {
|
||||||
if (v) {
|
if (v) {
|
||||||
// trigger a refresh
|
// trigger a refresh
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
}
|
}
|
||||||
|
mailbox.refresh = false;
|
||||||
mailbox.refresh = false
|
},
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
reloadMailbox() {
|
reloadMailbox() {
|
||||||
pagination.start = 0
|
pagination.start = 0;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
},
|
},
|
||||||
|
|
||||||
loadMessages() {
|
loadMessages() {
|
||||||
if (!this.apiURI) {
|
if (!this.apiURI) {
|
||||||
alert('apiURL not set!')
|
alert("apiURL not set!");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// auto-pagination changes the URL but should not fetch new messages
|
// auto-pagination changes the URL but should not fetch new messages
|
||||||
// when viewing page > 0 and new messages are received (inbox only)
|
// when viewing page > 0 and new messages are received (inbox only)
|
||||||
if (!mailbox.autoPaginating) {
|
if (!mailbox.autoPaginating) {
|
||||||
mailbox.autoPaginating = true // reset
|
mailbox.autoPaginating = true; // reset
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = {}
|
const params = {};
|
||||||
mailbox.selected = []
|
mailbox.selected = [];
|
||||||
|
|
||||||
params['limit'] = pagination.limit
|
params["limit"] = pagination.limit;
|
||||||
if (pagination.start > 0) {
|
if (pagination.start > 0) {
|
||||||
params['start'] = pagination.start
|
params["start"] = pagination.start;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.get(this.apiURI, params, (response) => {
|
this.get(this.apiURI, params, (response) => {
|
||||||
mailbox.total = response.data.total // all messages
|
mailbox.total = response.data.total; // all messages
|
||||||
mailbox.unread = response.data.unread // all unread messages
|
mailbox.unread = response.data.unread; // all unread messages
|
||||||
mailbox.tags = response.data.tags // all tags
|
mailbox.tags = response.data.tags; // all tags
|
||||||
mailbox.messages = response.data.messages // current messages
|
mailbox.messages = response.data.messages; // current messages
|
||||||
mailbox.count = response.data.messages_count // total results for this mailbox/search
|
mailbox.count = response.data.messages_count; // total results for this mailbox/search
|
||||||
mailbox.messages_unread = response.data.messages_unread // total unread results for this mailbox/search
|
mailbox.messages_unread = response.data.messages_unread; // total unread results for this mailbox/search
|
||||||
// ensure the pagination remains consistent
|
// ensure the pagination remains consistent
|
||||||
pagination.start = response.data.start
|
pagination.start = response.data.start;
|
||||||
|
|
||||||
if (response.data.count == 0 && response.data.start > 0) {
|
if (response.data.count === 0 && response.data.start > 0) {
|
||||||
pagination.start = 0
|
pagination.start = 0;
|
||||||
return this.loadMessages()
|
return this.loadMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mailbox.lastMessage) {
|
if (mailbox.lastMessage) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const m = document.getElementById(mailbox.lastMessage)
|
const m = document.getElementById(mailbox.lastMessage);
|
||||||
if (m) {
|
if (m) {
|
||||||
m.focus()
|
m.focus();
|
||||||
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
m.scrollIntoView({ block: 'center' })
|
m.scrollIntoView({ block: "center" });
|
||||||
} else {
|
} else {
|
||||||
const mp = document.getElementById('message-page')
|
const mp = document.getElementById("message-page");
|
||||||
if (mp) {
|
if (mp) {
|
||||||
mp.scrollTop = 0
|
mp.scrollTop = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mailbox.lastMessage = false
|
mailbox.lastMessage = false;
|
||||||
}, 50)
|
}, 50);
|
||||||
|
|
||||||
} else if (!window.scrollInPlace) {
|
} else if (!window.scrollInPlace) {
|
||||||
const mp = document.getElementById('message-page')
|
const mp = document.getElementById("message-page");
|
||||||
if (mp) {
|
if (mp) {
|
||||||
mp.scrollTop = 0
|
mp.scrollTop = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.scrollInPlace = false
|
window.scrollInPlace = false;
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import MailboxView from '../views/MailboxView.vue'
|
import MailboxView from "../views/MailboxView.vue";
|
||||||
import MessageView from '../views/MessageView.vue'
|
import MessageView from "../views/MessageView.vue";
|
||||||
import NotFoundView from '../views/NotFoundView.vue'
|
import NotFoundView from "../views/NotFoundView.vue";
|
||||||
import SearchView from '../views/SearchView.vue'
|
import SearchView from "../views/SearchView.vue";
|
||||||
|
|
||||||
let d = document.getElementById('app')
|
const d = document.getElementById("app");
|
||||||
let webroot = '/'
|
let webroot = "/";
|
||||||
if (d) {
|
if (d) {
|
||||||
webroot = d.dataset.webroot
|
webroot = d.dataset.webroot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// paths are relative to webroot
|
// paths are relative to webroot
|
||||||
@@ -15,23 +15,23 @@ const router = createRouter({
|
|||||||
history: createWebHistory(webroot),
|
history: createWebHistory(webroot),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: "/",
|
||||||
component: MailboxView
|
component: MailboxView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/search',
|
path: "/search",
|
||||||
component: SearchView
|
component: SearchView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/view/:id',
|
path: "/view/:id",
|
||||||
component: MessageView
|
component: MessageView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: "/:pathMatch(.*)*",
|
||||||
name: 'NotFound',
|
name: "NotFound",
|
||||||
component: NotFoundView
|
component: NotFoundView,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
export default router
|
export default router;
|
||||||
|
|||||||
@@ -1,92 +1,94 @@
|
|||||||
// State Management
|
// State Management
|
||||||
|
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from "vue";
|
||||||
|
|
||||||
// global mailbox info
|
// global mailbox info
|
||||||
export const mailbox = reactive({
|
export const mailbox = reactive({
|
||||||
total: 0, // total number of messages in database
|
total: 0, // total number of messages in database
|
||||||
unread: 0, // total unread messages in database
|
unread: 0, // total unread messages in database
|
||||||
count: 0, // total in mailbox or search
|
count: 0, // total in mailbox or search
|
||||||
messages: [], // current messages
|
messages: [], // current messages
|
||||||
tags: [], // all tags
|
tags: [], // all tags
|
||||||
selected: [], // currently selected
|
selected: [], // currently selected
|
||||||
connected: false, // websocket connection
|
connected: false, // websocket connection
|
||||||
searching: false, // current search, false for none
|
searching: false, // current search, false for none
|
||||||
refresh: false, // to listen from MessagesMixin
|
refresh: false, // to listen from MessagesMixin
|
||||||
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
|
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
|
||||||
notificationsSupported: false, // browser supports notifications
|
notificationsSupported: false, // browser supports notifications
|
||||||
notificationsEnabled: false, // user has enabled notifications
|
notificationsEnabled: false, // user has enabled notifications
|
||||||
skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read"
|
skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read"
|
||||||
appInfo: {}, // application information
|
appInfo: {}, // application information
|
||||||
uiConfig: {}, // configuration for UI
|
uiConfig: {}, // configuration for UI
|
||||||
lastMessage: false, // return scrolling
|
lastMessage: false, // return scrolling
|
||||||
|
|
||||||
// settings
|
// settings
|
||||||
showTagColors: !localStorage.getItem('hideTagColors') == '1',
|
showTagColors: !localStorage.getItem("hideTagColors"),
|
||||||
showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1',
|
showHTMLCheck: !localStorage.getItem("hideHTMLCheck"),
|
||||||
showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1',
|
showLinkCheck: !localStorage.getItem("hideLinkCheck"),
|
||||||
showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1',
|
showSpamCheck: !localStorage.getItem("hideSpamCheck"),
|
||||||
timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timeZone: localStorage.getItem("timeZone")
|
||||||
})
|
? localStorage.getItem("timeZone")
|
||||||
|
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => mailbox.count,
|
() => mailbox.count,
|
||||||
(v) => {
|
(v) => {
|
||||||
mailbox.selected = []
|
mailbox.selected = [];
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => mailbox.showTagColors,
|
() => mailbox.showTagColors,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
localStorage.removeItem('hideTagColors')
|
localStorage.removeItem("hideTagColors");
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('hideTagColors', '1')
|
localStorage.setItem("hideTagColors", "1");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => mailbox.showHTMLCheck,
|
() => mailbox.showHTMLCheck,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
localStorage.removeItem('hideHTMLCheck')
|
localStorage.removeItem("hideHTMLCheck");
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('hideHTMLCheck', '1')
|
localStorage.setItem("hideHTMLCheck", "1");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => mailbox.showLinkCheck,
|
() => mailbox.showLinkCheck,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
localStorage.removeItem('hideLinkCheck')
|
localStorage.removeItem("hideLinkCheck");
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('hideLinkCheck', '1')
|
localStorage.setItem("hideLinkCheck", "1");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => mailbox.showSpamCheck,
|
() => mailbox.showSpamCheck,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
localStorage.removeItem('hideSpamCheck')
|
localStorage.removeItem("hideSpamCheck");
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('hideSpamCheck', '1')
|
localStorage.setItem("hideSpamCheck", "1");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => mailbox.timeZone,
|
() => mailbox.timeZone,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) {
|
if (v === Intl.DateTimeFormat().resolvedOptions().timeZone) {
|
||||||
localStorage.removeItem('timeZone')
|
localStorage.removeItem("timeZone");
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('timeZone', v)
|
localStorage.setItem("timeZone", v);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from "vue";
|
||||||
|
|
||||||
export const pagination = reactive({
|
export const pagination = reactive({
|
||||||
start: 0, // pagination offset
|
start: 0, // pagination offset
|
||||||
limit: 50, // per page
|
limit: 50, // per page
|
||||||
defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit
|
defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit
|
||||||
total: 0, // total results of current view / filter
|
total: 0, // total results of current view / filter
|
||||||
count: 0, // number of messages currently displayed
|
count: 0, // number of messages currently displayed
|
||||||
})
|
});
|
||||||
|
|
||||||
export const limitOptions = [25, 50, 100, 200]
|
export const limitOptions = [25, 50, 100, 200];
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
import About from "../components/AppAbout.vue";
|
||||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
import AjaxLoader from "../components/AjaxLoader.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import ListMessages from '../components/ListMessages.vue'
|
import ListMessages from "../components/ListMessages.vue";
|
||||||
import MessagesMixins from '../mixins/MessagesMixins'
|
import MessagesMixins from "../mixins/MessagesMixins";
|
||||||
import NavMailbox from '../components/NavMailbox.vue'
|
import NavMailbox from "../components/NavMailbox.vue";
|
||||||
import NavTags from '../components/NavTags.vue'
|
import NavTags from "../components/NavTags.vue";
|
||||||
import Pagination from '../components/Pagination.vue'
|
import Pagination from "../components/NavPagination.vue";
|
||||||
import SearchForm from '../components/SearchForm.vue'
|
import SearchForm from "../components/SearchForm.vue";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
import { pagination } from "../stores/pagination";
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins, MessagesMixins],
|
|
||||||
|
|
||||||
// global event bus to handle message status changes
|
|
||||||
inject: ["eventBus"],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AboutMailpit,
|
About,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
ListMessages,
|
ListMessages,
|
||||||
NavMailbox,
|
NavMailbox,
|
||||||
@@ -27,111 +22,119 @@ export default {
|
|||||||
SearchForm,
|
SearchForm,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [CommonMixins, MessagesMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
delayedRefresh: false,
|
delayedRefresh: false,
|
||||||
paginationDelayed: false, // for delayed pagination URL changes
|
paginationDelayed: false, // for delayed pagination URL changes
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
$route(to, from) {
|
$route(to, from) {
|
||||||
this.loadMailbox()
|
this.loadMailbox();
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
mailbox.searching = false
|
mailbox.searching = false;
|
||||||
this.apiURI = this.resolve(`/api/v1/messages`)
|
this.apiURI = this.resolve(`/api/v1/messages`);
|
||||||
this.loadMailbox()
|
this.loadMailbox();
|
||||||
|
|
||||||
// subscribe to events
|
// subscribe to events
|
||||||
this.eventBus.on("new", this.handleWSNew)
|
this.eventBus.on("new", this.handleWSNew);
|
||||||
this.eventBus.on("update", this.handleWSUpdate)
|
this.eventBus.on("update", this.handleWSUpdate);
|
||||||
this.eventBus.on("delete", this.handleWSDelete)
|
this.eventBus.on("delete", this.handleWSDelete);
|
||||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
this.eventBus.on("truncate", this.handleWSTruncate);
|
||||||
},
|
},
|
||||||
|
|
||||||
unmounted() {
|
unmounted() {
|
||||||
// unsubscribe from events
|
// unsubscribe from events
|
||||||
this.eventBus.off("new", this.handleWSNew)
|
this.eventBus.off("new", this.handleWSNew);
|
||||||
this.eventBus.off("update", this.handleWSUpdate)
|
this.eventBus.off("update", this.handleWSUpdate);
|
||||||
this.eventBus.off("delete", this.handleWSDelete)
|
this.eventBus.off("delete", this.handleWSDelete);
|
||||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
this.eventBus.off("truncate", this.handleWSTruncate);
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
loadMailbox() {
|
loadMailbox() {
|
||||||
const paginationParams = this.getPaginationParams()
|
const paginationParams = this.getPaginationParams();
|
||||||
if (paginationParams?.start) {
|
if (paginationParams?.start) {
|
||||||
pagination.start = paginationParams.start
|
pagination.start = paginationParams.start;
|
||||||
} else {
|
} else {
|
||||||
pagination.start = 0
|
pagination.start = 0;
|
||||||
}
|
}
|
||||||
if (paginationParams?.limit) {
|
if (paginationParams?.limit) {
|
||||||
pagination.limit = paginationParams.limit
|
pagination.limit = paginationParams.limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
},
|
},
|
||||||
|
|
||||||
// This will only update the pagination offset at a maximum of 2x per second
|
// This will only update the pagination offset at a maximum of 2x per second
|
||||||
// when viewing the inbox on > page 1, while receiving an influx of new messages.
|
// when viewing the inbox on > page 1, while receiving an influx of new messages.
|
||||||
delayedPaginationUpdate() {
|
delayedPaginationUpdate() {
|
||||||
if (this.paginationDelayed) {
|
if (this.paginationDelayed) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.paginationDelayed = true
|
this.paginationDelayed = true;
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const path = this.$route.path
|
const path = this.$route.path;
|
||||||
const p = {
|
const p = {
|
||||||
...this.$route.query
|
...this.$route.query,
|
||||||
}
|
};
|
||||||
if (pagination.start > 0) {
|
if (pagination.start > 0) {
|
||||||
p.start = pagination.start.toString()
|
p.start = pagination.start.toString();
|
||||||
} else {
|
} else {
|
||||||
delete p.start
|
delete p.start;
|
||||||
}
|
}
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
p.limit = pagination.limit.toString()
|
p.limit = pagination.limit.toString();
|
||||||
} else {
|
} else {
|
||||||
delete p.limit
|
delete p.limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
mailbox.autoPaginating = false // prevent reload of messages when URL changes
|
mailbox.autoPaginating = false; // prevent reload of messages when URL changes
|
||||||
const params = new URLSearchParams(p)
|
const params = new URLSearchParams(p);
|
||||||
this.$router.replace(path + '?' + params.toString())
|
this.$router.replace(path + "?" + params.toString());
|
||||||
|
|
||||||
this.paginationDelayed = false
|
this.paginationDelayed = false;
|
||||||
}, 500)
|
}, 500);
|
||||||
},
|
},
|
||||||
|
|
||||||
// handler for websocket new messages
|
// handler for websocket new messages
|
||||||
handleWSNew(data) {
|
handleWSNew(data) {
|
||||||
if (pagination.start < 1) {
|
if (pagination.start < 1) {
|
||||||
// push results directly into first page
|
// push results directly into first page
|
||||||
mailbox.messages.unshift(data)
|
mailbox.messages.unshift(data);
|
||||||
if (mailbox.messages.length > pagination.limit) {
|
if (mailbox.messages.length > pagination.limit) {
|
||||||
mailbox.messages.pop()
|
mailbox.messages.pop();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// update pagination offset
|
// update pagination offset
|
||||||
pagination.start++
|
pagination.start++;
|
||||||
// prevent "Too many calls to Location or History APIs within a short time frame"
|
// prevent "Too many calls to Location or History APIs within a short time frame"
|
||||||
this.delayedPaginationUpdate()
|
this.delayedPaginationUpdate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// handler for websocket message updates
|
// handler for websocket message updates
|
||||||
handleWSUpdate(data) {
|
handleWSUpdate(data) {
|
||||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
if (this.mailbox.messages[x].ID == data.ID) {
|
if (this.mailbox.messages[x].ID === data.ID) {
|
||||||
// update message
|
// update message
|
||||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
this.mailbox.messages[x] = {
|
||||||
return
|
...this.mailbox.messages[x],
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -140,43 +143,43 @@ export default {
|
|||||||
handleWSDelete(data) {
|
handleWSDelete(data) {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
if (this.mailbox.messages[x].ID == data.ID) {
|
if (this.mailbox.messages[x].ID === data.ID) {
|
||||||
// remove message from the list
|
// remove message from the list
|
||||||
this.mailbox.messages.splice(x, 1)
|
this.mailbox.messages.splice(x, 1);
|
||||||
removed++
|
removed++;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!removed || this.delayedRefresh) {
|
if (!removed || this.delayedRefresh) {
|
||||||
// nothing changed on this screen, or a refresh is queued,
|
// nothing changed on this screen, or a refresh is queued,
|
||||||
// don't refresh
|
// don't refresh
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||||
this.delayedRefresh = true
|
this.delayedRefresh = true;
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.delayedRefresh = false
|
this.delayedRefresh = false;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
}, 500)
|
}, 500);
|
||||||
},
|
},
|
||||||
|
|
||||||
// handler for websocket message truncation
|
// handler for websocket message truncation
|
||||||
handleWSTruncate() {
|
handleWSTruncate() {
|
||||||
// all messages gone, reload
|
// all messages gone, reload
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
||||||
<div class="col-xl-2 col-md-3 col-auto pe-0">
|
<div class="col-xl-2 col-md-3 col-auto pe-0">
|
||||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox">
|
<RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox">
|
||||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
<img :src="resolve('/mailpit.svg')" alt="Mailpit" />
|
||||||
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,8 +188,13 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0">
|
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0">
|
||||||
<div class="float-start d-md-none">
|
<div class="float-start d-md-none">
|
||||||
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
|
<button
|
||||||
data-bs-target="#offcanvas" aria-controls="offcanvas">
|
class="btn btn-outline-light me-2"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#offcanvas"
|
||||||
|
aria-controls="offcanvas"
|
||||||
|
>
|
||||||
<i class="bi bi-list"></i>
|
<i class="bi bi-list"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,41 +202,51 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
|
<div
|
||||||
aria-labelledby="offcanvasLabel">
|
id="offcanvas"
|
||||||
|
class="offcanvas-md offcanvas-start d-md-none"
|
||||||
|
data-bs-scroll="true"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="offcanvasLabel"
|
||||||
|
>
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
|
<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
<button
|
||||||
aria-label="Close"></button>
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="offcanvas"
|
||||||
|
data-bs-target="#offcanvas"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body pb-0">
|
<div class="offcanvas-body pb-0">
|
||||||
<div class="d-flex flex-column h-100">
|
<div class="d-flex flex-column h-100">
|
||||||
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||||
<NavMailbox @loadMessages="loadMessages" />
|
<NavMailbox @load-messages="loadMessages" />
|
||||||
<NavTags />
|
<NavTags />
|
||||||
</div>
|
</div>
|
||||||
<AboutMailpit />
|
<About />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row flex-fill" style="min-height:0">
|
<div class="row flex-fill" style="min-height: 0">
|
||||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||||
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||||
<NavMailbox @loadMessages="loadMessages" />
|
<NavMailbox @load-messages="loadMessages" />
|
||||||
<NavTags />
|
<NavTags />
|
||||||
</div>
|
</div>
|
||||||
<AboutMailpit />
|
<About />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
|
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||||
<div class="mh-100" style="overflow-y: auto;" id="message-page">
|
<div id="message-page" class="mh-100" style="overflow-y: auto">
|
||||||
<ListMessages :loading-messages="loading" />
|
<ListMessages :loading-messages="loading" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavMailbox @loadMessages="loadMessages" modals />
|
<NavMailbox modals @load-messages="loadMessages" />
|
||||||
<AboutMailpit modals />
|
<About modals />
|
||||||
<AjaxLoader :loading="loading" />
|
<AjaxLoader :loading="loading" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
import AboutMailpit from "../components/AppAbout.vue";
|
||||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
import AjaxLoader from "../components/AjaxLoader.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import Message from '../components/message/Message.vue'
|
import Message from "../components/message/MessageItem.vue";
|
||||||
import Release from '../components/message/Release.vue'
|
import Release from "../components/message/MessageRelease.vue";
|
||||||
import Screenshot from '../components/message/Screenshot.vue'
|
import Screenshot from "../components/message/MessageScreenshot.vue";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
import { pagination } from '../stores/pagination'
|
import { pagination } from "../stores/pagination";
|
||||||
import dayjs from 'dayjs'
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
// global event bus to handle message status changes
|
|
||||||
inject: ["eventBus"],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AboutMailpit,
|
AboutMailpit,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
@@ -23,6 +18,11 @@ export default {
|
|||||||
Release,
|
Release,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
@@ -36,203 +36,206 @@ export default {
|
|||||||
liveLoaded: 0, // the number new messages prepended tp messageList
|
liveLoaded: 0, // the number new messages prepended tp messageList
|
||||||
scrollLoading: false,
|
scrollLoading: false,
|
||||||
canLoadMore: true,
|
canLoadMore: true,
|
||||||
}
|
};
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
$route(to, from) {
|
|
||||||
this.loadMessage()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
this.initLoadMoreAPIParams()
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.loadMessage()
|
|
||||||
|
|
||||||
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
|
|
||||||
if (!this.messagesList.length) {
|
|
||||||
this.loadMore()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.refreshUI()
|
|
||||||
|
|
||||||
// subscribe to events
|
|
||||||
this.eventBus.on("new", this.handleWSNew)
|
|
||||||
this.eventBus.on("update", this.handleWSUpdate)
|
|
||||||
this.eventBus.on("delete", this.handleWSDelete)
|
|
||||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
|
||||||
},
|
|
||||||
|
|
||||||
unmounted() {
|
|
||||||
// unsubscribe from events
|
|
||||||
this.eventBus.off("new", this.handleWSNew)
|
|
||||||
this.eventBus.off("update", this.handleWSUpdate)
|
|
||||||
this.eventBus.off("delete", this.handleWSDelete)
|
|
||||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
// get current message read status
|
// get current message read status
|
||||||
isRead() {
|
isRead() {
|
||||||
const l = this.messagesList.length
|
const l = this.messagesList.length;
|
||||||
if (!this.message || !l) {
|
if (!this.message || !l) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = false
|
for (let x = 0; x < l; x++) {
|
||||||
for (x = 0; x < l; x++) {
|
if (this.messagesList[x].ID === this.message.ID) {
|
||||||
if (this.messagesList[x].ID == this.message.ID) {
|
return this.messagesList[x].Read;
|
||||||
return this.messagesList[x].Read
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// get the previous message ID
|
// get the previous message ID
|
||||||
previousID() {
|
previousID() {
|
||||||
const l = this.messagesList.length
|
const l = this.messagesList.length;
|
||||||
if (!this.message || !l) {
|
if (!this.message || !l) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = false
|
let id = false;
|
||||||
for (x = 0; x < l; x++) {
|
for (let x = 0; x < l; x++) {
|
||||||
if (this.messagesList[x].ID == this.message.ID) {
|
if (this.messagesList[x].ID === this.message.ID) {
|
||||||
return id
|
return id;
|
||||||
}
|
}
|
||||||
id = this.messagesList[x].ID
|
id = this.messagesList[x].ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// get the next message ID
|
// get the next message ID
|
||||||
nextID() {
|
nextID() {
|
||||||
const l = this.messagesList.length
|
const l = this.messagesList.length;
|
||||||
if (!this.message || !l) {
|
if (!this.message || !l) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = false
|
let id = false;
|
||||||
for (x = l - 1; x > 0; x--) {
|
for (let x = l - 1; x > 0; x--) {
|
||||||
if (this.messagesList[x].ID == this.message.ID) {
|
if (this.messagesList[x].ID === this.message.ID) {
|
||||||
return id
|
return id;
|
||||||
}
|
}
|
||||||
id = this.messagesList[x].ID
|
id = this.messagesList[x].ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
return id
|
return id;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
$route(to, from) {
|
||||||
|
this.loadMessage();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const relativeTime = require("dayjs/plugin/relativeTime");
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
this.initLoadMoreAPIParams();
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.loadMessage();
|
||||||
|
|
||||||
|
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages));
|
||||||
|
if (!this.messagesList.length) {
|
||||||
|
this.loadMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.refreshUI();
|
||||||
|
|
||||||
|
// subscribe to events
|
||||||
|
this.eventBus.on("new", this.handleWSNew);
|
||||||
|
this.eventBus.on("update", this.handleWSUpdate);
|
||||||
|
this.eventBus.on("delete", this.handleWSDelete);
|
||||||
|
this.eventBus.on("truncate", this.handleWSTruncate);
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
// unsubscribe from events
|
||||||
|
this.eventBus.off("new", this.handleWSNew);
|
||||||
|
this.eventBus.off("update", this.handleWSUpdate);
|
||||||
|
this.eventBus.off("delete", this.handleWSDelete);
|
||||||
|
this.eventBus.off("truncate", this.handleWSTruncate);
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
loadMessage() {
|
loadMessage() {
|
||||||
this.message = false
|
this.message = false;
|
||||||
const uri = this.resolve('/api/v1/message/' + this.$route.params.id)
|
const uri = this.resolve("/api/v1/message/" + this.$route.params.id);
|
||||||
this.get(uri, false, (response) => {
|
this.get(
|
||||||
this.errorMessage = false
|
uri,
|
||||||
const d = response.data
|
false,
|
||||||
|
(response) => {
|
||||||
|
this.errorMessage = false;
|
||||||
|
const d = response.data;
|
||||||
|
|
||||||
// update read status in case websockets is not working
|
// update read status in case websockets is not working
|
||||||
this.handleWSUpdate({ 'ID': d.ID, Read: true })
|
this.handleWSUpdate({ ID: d.ID, Read: true });
|
||||||
|
|
||||||
// replace inline images embedded as inline attachments
|
// replace inline images embedded as inline attachments
|
||||||
if (d.HTML && d.Inline) {
|
if (d.HTML && d.Inline) {
|
||||||
for (let i in d.Inline) {
|
for (const i in d.Inline) {
|
||||||
let a = d.Inline[i]
|
const a = d.Inline[i];
|
||||||
if (a.ContentID != '') {
|
if (a.ContentID !== "") {
|
||||||
d.HTML = d.HTML.replace(
|
d.HTML = d.HTML.replace(
|
||||||
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
|
if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) {
|
||||||
// some old email clients use the filename
|
// some old email clients use the filename
|
||||||
d.HTML = d.HTML.replace(
|
d.HTML = d.HTML.replace(
|
||||||
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||||
)
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// replace inline images embedded as regular attachments
|
// replace inline images embedded as regular attachments
|
||||||
if (d.HTML && d.Attachments) {
|
if (d.HTML && d.Attachments) {
|
||||||
for (let i in d.Attachments) {
|
for (const i in d.Attachments) {
|
||||||
let a = d.Attachments[i]
|
const a = d.Attachments[i];
|
||||||
if (a.ContentID != '') {
|
if (a.ContentID !== "") {
|
||||||
d.HTML = d.HTML.replace(
|
d.HTML = d.HTML.replace(
|
||||||
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
|
if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) {
|
||||||
// some old email clients use the filename
|
// some old email clients use the filename
|
||||||
d.HTML = d.HTML.replace(
|
d.HTML = d.HTML.replace(
|
||||||
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||||
)
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.message = d
|
this.message = d;
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.scrollSidebarToCurrent()
|
this.scrollSidebarToCurrent();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.errorMessage = true
|
this.errorMessage = true;
|
||||||
if (error.response && error.response.data) {
|
if (error.response && error.response.data) {
|
||||||
if (error.response.data.Error) {
|
if (error.response.data.Error) {
|
||||||
this.errorMessage = error.response.data.Error
|
this.errorMessage = error.response.data.Error;
|
||||||
} else {
|
} else {
|
||||||
this.errorMessage = error.response.data
|
this.errorMessage = error.response.data;
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
// The request was made but no response was received
|
// The request was made but no response was received
|
||||||
this.errorMessage = 'Error sending data to the server. Please refresh the page.'
|
this.errorMessage = "Error sending data to the server. Please refresh the page.";
|
||||||
} else {
|
} else {
|
||||||
// Something happened in setting up the request that triggered an Error
|
// Something happened in setting up the request that triggered an Error
|
||||||
this.errorMessage = error.message
|
this.errorMessage = error.message;
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// UI refresh ticker to adjust relative times
|
// UI refresh ticker to adjust relative times
|
||||||
refreshUI() {
|
refreshUI() {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.$forceUpdate()
|
this.$forceUpdate();
|
||||||
this.refreshUI()
|
this.refreshUI();
|
||||||
}, 30000)
|
}, 30000);
|
||||||
},
|
},
|
||||||
|
|
||||||
// handler for websocket new messages
|
// handler for websocket new messages
|
||||||
handleWSNew(data) {
|
handleWSNew(data) {
|
||||||
// do not add when searching or >= 100 new messages have been received
|
// do not add when searching or >= 100 new messages have been received
|
||||||
if (this.mailbox.searching || this.liveLoaded >= 100) {
|
if (this.mailbox.searching || this.liveLoaded >= 100) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.liveLoaded++
|
this.liveLoaded++;
|
||||||
this.messagesList.unshift(data)
|
this.messagesList.unshift(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
// handler for websocket message updates
|
// handler for websocket message updates
|
||||||
handleWSUpdate(data) {
|
handleWSUpdate(data) {
|
||||||
for (let x = 0; x < this.messagesList.length; x++) {
|
for (let x = 0; x < this.messagesList.length; x++) {
|
||||||
if (this.messagesList[x].ID == data.ID) {
|
if (this.messagesList[x].ID === data.ID) {
|
||||||
// update message
|
// update message
|
||||||
this.messagesList[x] = { ...this.messagesList[x], ...data }
|
this.messagesList[x] = { ...this.messagesList[x], ...data };
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -240,10 +243,10 @@ export default {
|
|||||||
// handler for websocket message deletion
|
// handler for websocket message deletion
|
||||||
handleWSDelete(data) {
|
handleWSDelete(data) {
|
||||||
for (let x = 0; x < this.messagesList.length; x++) {
|
for (let x = 0; x < this.messagesList.length; x++) {
|
||||||
if (this.messagesList[x].ID == data.ID) {
|
if (this.messagesList[x].ID === data.ID) {
|
||||||
// remove message from the list
|
// remove message from the list
|
||||||
this.messagesList.splice(x, 1)
|
this.messagesList.splice(x, 1);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -251,277 +254,299 @@ export default {
|
|||||||
// handler for websocket message truncation
|
// handler for websocket message truncation
|
||||||
handleWSTruncate() {
|
handleWSTruncate() {
|
||||||
// all messages gone, go to inbox
|
// all messages gone, go to inbox
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
},
|
},
|
||||||
|
|
||||||
// return whether the sidebar is visible
|
// return whether the sidebar is visible
|
||||||
sidebarVisible() {
|
sidebarVisible() {
|
||||||
return this.$refs.MessageList.offsetParent != null
|
return this.$refs.MessageList.offsetParent !== null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// scroll sidenav to current message if found
|
// scroll sidenav to current message if found
|
||||||
scrollSidebarToCurrent() {
|
scrollSidebarToCurrent() {
|
||||||
const cont = document.getElementById('MessageList')
|
const cont = document.getElementById("MessageList");
|
||||||
if (!cont) {
|
if (!cont) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const c = cont.querySelector('.router-link-active')
|
const c = cont.querySelector(".router-link-active");
|
||||||
if (c) {
|
if (c) {
|
||||||
const outer = cont.getBoundingClientRect()
|
const outer = cont.getBoundingClientRect();
|
||||||
const li = c.getBoundingClientRect()
|
const li = c.getBoundingClientRect();
|
||||||
if (outer.top > li.top || outer.bottom < li.bottom) {
|
if (outer.top > li.top || outer.bottom < li.bottom) {
|
||||||
c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
|
c.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
inline: "nearest",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
scrollHandler(e) {
|
scrollHandler(e) {
|
||||||
if (!this.canLoadMore || this.scrollLoading) {
|
if (!this.canLoadMore || this.scrollLoading) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { scrollTop, offsetHeight, scrollHeight } = e.target
|
const { scrollTop, offsetHeight, scrollHeight } = e.target;
|
||||||
if ((scrollTop + offsetHeight + 150) >= scrollHeight) {
|
if (scrollTop + offsetHeight + 150 >= scrollHeight) {
|
||||||
this.loadMore()
|
this.loadMore();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loadMore() {
|
loadMore() {
|
||||||
if (this.messagesList.length) {
|
if (this.messagesList.length) {
|
||||||
// get last created timestamp
|
// get last created timestamp
|
||||||
const oldest = this.messagesList[this.messagesList.length - 1].Created
|
const oldest = this.messagesList[this.messagesList.length - 1].Created;
|
||||||
// if set append `before=<ts>`
|
// if set append `before=<ts>`
|
||||||
this.apiSideNavParams.set('before', oldest)
|
this.apiSideNavParams.set("before", oldest);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scrollLoading = true
|
this.scrollLoading = true;
|
||||||
|
|
||||||
this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => {
|
this.get(
|
||||||
if (response.data.messages.length) {
|
this.apiSideNavURI,
|
||||||
this.messagesList.push(...response.data.messages)
|
this.apiSideNavParams,
|
||||||
} else {
|
(response) => {
|
||||||
this.canLoadMore = false
|
if (response.data.messages.length) {
|
||||||
}
|
this.messagesList.push(...response.data.messages);
|
||||||
this.$nextTick(() => {
|
} else {
|
||||||
this.scrollLoading = false
|
this.canLoadMore = false;
|
||||||
})
|
}
|
||||||
}, null, true)
|
this.$nextTick(() => {
|
||||||
|
this.scrollLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
initLoadMoreAPIParams() {
|
initLoadMoreAPIParams() {
|
||||||
let apiURI = this.resolve(`/api/v1/messages`)
|
let apiURI = this.resolve(`/api/v1/messages`);
|
||||||
let p = {}
|
const p = {};
|
||||||
|
|
||||||
if (mailbox.searching) {
|
if (mailbox.searching) {
|
||||||
apiURI = this.resolve(`/api/v1/search`)
|
apiURI = this.resolve(`/api/v1/search`);
|
||||||
p.query = mailbox.searching
|
p.query = mailbox.searching;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
p.limit = pagination.limit.toString()
|
p.limit = pagination.limit.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apiSideNavURI = apiURI
|
this.apiSideNavURI = apiURI;
|
||||||
|
|
||||||
this.apiSideNavParams = new URLSearchParams(p)
|
this.apiSideNavParams = new URLSearchParams(p);
|
||||||
},
|
},
|
||||||
|
|
||||||
getRelativeCreated(message) {
|
getRelativeCreated(message) {
|
||||||
const d = new Date(message.Created)
|
const d = new Date(message.Created);
|
||||||
return dayjs(d).fromNow()
|
return dayjs(d).fromNow();
|
||||||
},
|
},
|
||||||
|
|
||||||
getPrimaryEmailTo(message) {
|
getPrimaryEmailTo(message) {
|
||||||
for (let i in message.To) {
|
if (message.To && message.To.length > 0) {
|
||||||
return message.To[i].Address
|
return message.To[0].Address;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '[ Undisclosed recipients ]'
|
return "[ Undisclosed recipients ]";
|
||||||
},
|
},
|
||||||
|
|
||||||
isActive(id) {
|
isActive(id) {
|
||||||
return this.message.ID == id
|
return this.message.ID === id;
|
||||||
},
|
},
|
||||||
|
|
||||||
toTagUrl(t) {
|
toTagUrl(t) {
|
||||||
if (t.match(/ /)) {
|
if (t.match(/ /)) {
|
||||||
t = `"${t}"`
|
t = `"${t}"`;
|
||||||
}
|
}
|
||||||
const p = {
|
const p = {
|
||||||
q: 'tag:' + t
|
q: "tag:" + t,
|
||||||
|
};
|
||||||
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
|
p.limit = pagination.limit.toString();
|
||||||
}
|
}
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
const params = new URLSearchParams(p);
|
||||||
p.limit = pagination.limit.toString()
|
return "/search?" + params.toString();
|
||||||
}
|
|
||||||
const params = new URLSearchParams(p)
|
|
||||||
return '/search?' + params.toString()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadMessageBody(str, ext) {
|
downloadMessageBody(str, ext) {
|
||||||
const dl = document.createElement('a')
|
const dl = document.createElement("a");
|
||||||
dl.href = "data:text/plain," + encodeURIComponent(str)
|
dl.href = "data:text/plain," + encodeURIComponent(str);
|
||||||
dl.target = '_blank'
|
dl.target = "_blank";
|
||||||
dl.download = this.message.ID + '.' + ext
|
dl.download = this.message.ID + "." + ext;
|
||||||
dl.click()
|
dl.click();
|
||||||
},
|
},
|
||||||
|
|
||||||
screenshotMessageHTML() {
|
screenshotMessageHTML() {
|
||||||
this.$refs.ScreenshotRef.initScreenshot()
|
this.$refs.ScreenshotRef.initScreenshot();
|
||||||
},
|
},
|
||||||
|
|
||||||
// toggle current message read status
|
// toggle current message read status
|
||||||
toggleRead() {
|
toggleRead() {
|
||||||
if (!this.message) {
|
if (!this.message) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
const read = !this.isRead
|
const read = !this.isRead;
|
||||||
|
|
||||||
const ids = [this.message.ID]
|
const ids = [this.message.ID];
|
||||||
const uri = this.resolve('/api/v1/messages')
|
const uri = this.resolve("/api/v1/messages");
|
||||||
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
|
this.put(uri, { Read: read, IDs: ids }, () => {
|
||||||
if (!this.sidebarVisible()) {
|
if (!this.sidebarVisible()) {
|
||||||
return this.goBack()
|
return this.goBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
// manually update read status in case websockets is not working
|
// manually update read status in case websockets is not working
|
||||||
this.handleWSUpdate({ 'ID': this.message.ID, Read: read })
|
this.handleWSUpdate({ ID: this.message.ID, Read: read });
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteMessage() {
|
deleteMessage() {
|
||||||
const ids = [this.message.ID]
|
const ids = [this.message.ID];
|
||||||
const uri = this.resolve('/api/v1/messages')
|
const uri = this.resolve("/api/v1/messages");
|
||||||
// calculate next ID before deletion to prevent WS race
|
// calculate next ID before deletion to prevent WS race
|
||||||
const goToID = this.nextID ? this.nextID : this.previousID
|
const goToID = this.nextID ? this.nextID : this.previousID;
|
||||||
|
|
||||||
this.delete(uri, { 'IDs': ids }, () => {
|
this.delete(uri, { IDs: ids }, () => {
|
||||||
if (!this.sidebarVisible()) {
|
if (!this.sidebarVisible()) {
|
||||||
return this.goBack()
|
return this.goBack();
|
||||||
}
|
}
|
||||||
if (goToID) {
|
if (goToID) {
|
||||||
return this.$router.push('/view/' + goToID)
|
return this.$router.push("/view/" + goToID);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.goBack()
|
return this.goBack();
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// return to mailbox or search based on origin
|
// return to mailbox or search based on origin
|
||||||
goBack() {
|
goBack() {
|
||||||
mailbox.lastMessage = this.$route.params.id
|
mailbox.lastMessage = this.$route.params.id;
|
||||||
|
|
||||||
if (mailbox.searching) {
|
if (mailbox.searching) {
|
||||||
const p = {
|
const p = {
|
||||||
q: mailbox.searching
|
q: mailbox.searching,
|
||||||
}
|
};
|
||||||
if (pagination.start > 0) {
|
if (pagination.start > 0) {
|
||||||
p.start = pagination.start.toString()
|
p.start = pagination.start.toString();
|
||||||
}
|
}
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
p.limit = pagination.limit.toString()
|
p.limit = pagination.limit.toString();
|
||||||
}
|
}
|
||||||
this.$router.push('/search?' + new URLSearchParams(p).toString())
|
this.$router.push("/search?" + new URLSearchParams(p).toString());
|
||||||
} else {
|
} else {
|
||||||
const p = {}
|
const p = {};
|
||||||
if (pagination.start > 0) {
|
if (pagination.start > 0) {
|
||||||
p.start = pagination.start.toString()
|
p.start = pagination.start.toString();
|
||||||
}
|
}
|
||||||
if (pagination.limit != pagination.defaultLimit) {
|
if (pagination.limit !== pagination.defaultLimit) {
|
||||||
p.limit = pagination.limit.toString()
|
p.limit = pagination.limit.toString();
|
||||||
}
|
}
|
||||||
this.$router.push('/?' + new URLSearchParams(p).toString())
|
this.$router.push("/?" + new URLSearchParams(p).toString());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
reloadWindow() {
|
reloadWindow() {
|
||||||
location.reload()
|
location.reload();
|
||||||
},
|
},
|
||||||
|
|
||||||
initReleaseModal() {
|
initReleaseModal() {
|
||||||
this.modal('ReleaseModal').show()
|
this.modal("ReleaseModal").show();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
// delay to allow elements to load / focus
|
// delay to allow elements to load / focus
|
||||||
this.$refs.ReleaseRef.initTags()
|
this.$refs.ReleaseRef.initTags();
|
||||||
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
|
document.querySelector('#ReleaseModal input[role="combobox"]').focus();
|
||||||
}, 500)
|
}, 500);
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
||||||
<div class="d-none d-xl-block col-xl-3 col-auto pe-0">
|
<div class="d-none d-xl-block col-xl-3 col-auto pe-0">
|
||||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
<img :src="resolve('/mailpit.svg')" alt="Mailpit" />
|
||||||
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-xl-5" v-if="!errorMessage">
|
<div v-if="!errorMessage" class="col col-xl-5">
|
||||||
<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages">
|
<button class="btn btn-outline-light me-3 d-xl-none" title="Return to messages" @click="goBack()">
|
||||||
<i class="bi bi-arrow-return-left"></i>
|
<i class="bi bi-arrow-return-left"></i>
|
||||||
<span class="ms-2 d-none d-lg-inline">Back</span>
|
<span class="ms-2 d-none d-lg-inline">Back</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()">
|
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" @click="toggleRead()">
|
||||||
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
|
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
|
||||||
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
|
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
|
<button
|
||||||
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
|
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
|
||||||
v-on:click="initReleaseModal()">
|
class="btn btn-outline-light me-1 me-sm-2"
|
||||||
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
|
title="Release message"
|
||||||
|
@click="initReleaseModal()"
|
||||||
|
>
|
||||||
|
<i class="bi bi-send"></i>
|
||||||
|
<span class="d-none d-md-inline">Release</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()">
|
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" @click="deleteMessage()">
|
||||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
<span class="d-none d-md-inline">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto col-lg-4 col-xl-4 text-end" v-if="!errorMessage">
|
<div v-if="!errorMessage" class="col-auto col-lg-4 col-xl-4 text-end">
|
||||||
<div class="dropdown d-inline-block" id="DownloadBtn">
|
<div id="DownloadBtn" class="dropdown d-inline-block">
|
||||||
<button type="button" class="btn btn-outline-light dropdown-toggle" data-bs-toggle="dropdown"
|
<button
|
||||||
aria-expanded="false">
|
type="button"
|
||||||
|
class="btn btn-outline-light dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
<i class="bi bi-file-arrow-down-fill"></i>
|
<i class="bi bi-file-arrow-down-fill"></i>
|
||||||
<span class="d-none d-md-inline ms-1">Download</span>
|
<span class="d-none d-md-inline ms-1">Download</span>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li>
|
<li>
|
||||||
<a :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" class="dropdown-item"
|
<a
|
||||||
title="Message source including headers, body and attachments">
|
:href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')"
|
||||||
|
class="dropdown-item"
|
||||||
|
title="Message source including headers, body and attachments"
|
||||||
|
>
|
||||||
Raw message
|
Raw message
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="message.HTML">
|
<li v-if="message.HTML">
|
||||||
<button v-on:click="downloadMessageBody(message.HTML, 'html')" class="dropdown-item">
|
<button class="dropdown-item" @click="downloadMessageBody(message.HTML, 'html')">
|
||||||
HTML body
|
HTML body
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="message.HTML">
|
<li v-if="message.HTML">
|
||||||
<button class="dropdown-item" @click="screenshotMessageHTML()">
|
<button class="dropdown-item" @click="screenshotMessageHTML()">HTML screenshot</button>
|
||||||
HTML screenshot
|
|
||||||
</button>
|
|
||||||
</li>
|
</li>
|
||||||
<li v-if="message.Text">
|
<li v-if="message.Text">
|
||||||
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item">
|
<button class="dropdown-item" @click="downloadMessageBody(message.Text, 'txt')">
|
||||||
Text body
|
Text body
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<template v-if="message.Attachments && message.Attachments.length">
|
<template v-if="message.Attachments && message.Attachments.length">
|
||||||
<li>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider" />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h6 class="dropdown-header">
|
<h6 class="dropdown-header">Attachments</h6>
|
||||||
Attachments
|
|
||||||
</h6>
|
|
||||||
</li>
|
</li>
|
||||||
<li v-for="part in message.Attachments">
|
<li v-for="part in message.Attachments" :key="part.PartID">
|
||||||
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
<RouterLink
|
||||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
class="row m-0 dropdown-item d-flex"
|
||||||
|
target="_blank"
|
||||||
|
:title="part.FileName !== '' ? part.FileName : '[ unknown ]'"
|
||||||
|
style="min-width: 350px"
|
||||||
|
>
|
||||||
<div class="col-auto p-0 pe-1">
|
<div class="col-auto p-0 pe-1">
|
||||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="col text-truncate p-0 pe-1">
|
<div class="col text-truncate p-0 pe-1">
|
||||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto text-muted small p-0">
|
<div class="col-auto text-muted small p-0">
|
||||||
{{ getFileSize(part.Size) }}
|
{{ getFileSize(part.Size) }}
|
||||||
@@ -531,22 +556,24 @@ export default {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="message.Inline && message.Inline.length">
|
<template v-if="message.Inline && message.Inline.length">
|
||||||
<li>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider" />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h6 class="dropdown-header">
|
<h6 class="dropdown-header">Inline image<span v-if="message.Inline.length > 1">s</span></h6>
|
||||||
Inline image<span v-if="message.Inline.length > 1">s</span>
|
|
||||||
</h6>
|
|
||||||
</li>
|
</li>
|
||||||
<li v-for="part in message.Inline">
|
<li v-for="part in message.Inline" :key="part.PartID">
|
||||||
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
<RouterLink
|
||||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
class="row m-0 dropdown-item d-flex"
|
||||||
|
target="_blank"
|
||||||
|
:title="part.FileName !== '' ? part.FileName : '[ unknown ]'"
|
||||||
|
style="min-width: 350px"
|
||||||
|
>
|
||||||
<div class="col-auto p-0 pe-1">
|
<div class="col-auto p-0 pe-1">
|
||||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="col text-truncate p-0 pe-1">
|
<div class="col text-truncate p-0 pe-1">
|
||||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto text-muted small p-0">
|
<div class="col-auto text-muted small p-0">
|
||||||
{{ getFileSize(part.Size) }}
|
{{ getFileSize(part.Size) }}
|
||||||
@@ -557,8 +584,12 @@ export default {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
<RouterLink
|
||||||
:class="previousID ? '' : 'disabled'" title="View previous message">
|
:to="'/view/' + previousID"
|
||||||
|
class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||||
|
:class="previousID ? '' : 'disabled'"
|
||||||
|
title="View previous message"
|
||||||
|
>
|
||||||
<i class="bi bi-caret-left-fill"></i>
|
<i class="bi bi-caret-left-fill"></i>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
|
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
|
||||||
@@ -567,69 +598,89 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row flex-fill" style="min-height:0">
|
<div class="row flex-fill" style="min-height: 0">
|
||||||
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
|
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
|
||||||
<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">
|
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||||
{{ mailbox.uiConfig.Label }}
|
{{ mailbox.uiConfig.Label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||||
<button @click="goBack()" class="list-group-item list-group-item-action">
|
<button class="list-group-item list-group-item-action" @click="goBack()">
|
||||||
<i class="bi bi-arrow-return-left me-1"></i>
|
<i class="bi bi-arrow-return-left me-1"></i>
|
||||||
<span class="ms-1">
|
<span class="ms-1">
|
||||||
Return to
|
Return to
|
||||||
<template v-if="mailbox.searching">search</template>
|
<template v-if="mailbox.searching">search</template>
|
||||||
<template v-else>inbox</template>
|
<template v-else>inbox</template>
|
||||||
</span>
|
</span>
|
||||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
<span
|
||||||
v-if="mailbox.unread && !errorMessage">
|
v-if="mailbox.unread && !errorMessage"
|
||||||
|
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||||
|
title="Unread messages"
|
||||||
|
>
|
||||||
{{ formatNumber(mailbox.unread) }}
|
{{ formatNumber(mailbox.unread) }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
|
<div
|
||||||
@scroll="scrollHandler">
|
id="MessageList"
|
||||||
|
ref="MessageList"
|
||||||
|
class="flex-grow-1 overflow-y-auto px-1 me-n1"
|
||||||
|
@scroll="scrollHandler"
|
||||||
|
>
|
||||||
<button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()">
|
<button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()">
|
||||||
Reload to see newer messages
|
Reload to see newer messages
|
||||||
</button>
|
</button>
|
||||||
<template v-if="messagesList && messagesList.length">
|
<template v-if="messagesList && messagesList.length">
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
|
<RouterLink
|
||||||
:id="message.ID"
|
v-for="summary in messagesList"
|
||||||
|
:id="summary.ID"
|
||||||
|
:key="'summary_' + summary.ID"
|
||||||
|
:to="'/view/' + summary.ID"
|
||||||
class="row gx-1 message d-flex small list-group-item list-group-item-action message"
|
class="row gx-1 message d-flex small list-group-item list-group-item-action message"
|
||||||
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
|
:class="[summary.Read ? 'read' : '', isActive(summary.ID) ? 'active' : '']"
|
||||||
|
>
|
||||||
<div class="col overflow-x-hidden">
|
<div class="col overflow-x-hidden">
|
||||||
<div class="text-truncate privacy small">
|
<div class="text-truncate privacy small">
|
||||||
<strong v-if="message.From" :title="'From: ' + message.From.Address">
|
<strong v-if="summary.From" :title="'From: ' + summary.From.Address">
|
||||||
{{ message.From.Name ? message.From.Name : message.From.Address }}
|
{{ summary.From.Name ? summary.From.Name : summary.From.Address }}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto small">
|
<div class="col-auto small">
|
||||||
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i>
|
<i v-if="summary.Attachments" class="bi bi-paperclip h6"></i>
|
||||||
{{ getRelativeCreated(message) }}
|
{{ getRelativeCreated(summary) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 overflow-x-hidden">
|
<div class="col-12 overflow-x-hidden">
|
||||||
<div class="text-truncate privacy small">
|
<div class="text-truncate privacy small">
|
||||||
To: {{ getPrimaryEmailTo(message) }}
|
To: {{ getPrimaryEmailTo(summary) }}
|
||||||
<span v-if="message.To && message.To.length > 1">
|
<span v-if="summary.To && summary.To.length > 1">
|
||||||
[+{{ message.To.length - 1 }}]
|
[+{{ summary.To.length - 1 }}]
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 overflow-x-hidden mt-1">
|
<div class="col-12 overflow-x-hidden mt-1">
|
||||||
<div class="text-truncates small">
|
<div class="text-truncates small">
|
||||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
<b>{{ summary.Subject !== "" ? summary.Subject : "[ no subject ]" }}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="message.Tags.length" class="col-12">
|
<div v-if="summary.Tags.length" class="col-12">
|
||||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
<RouterLink
|
||||||
v-on:click="pagination.start = 0"
|
v-for="t in summary.Tags"
|
||||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
:key="t"
|
||||||
:title="'Filter messages tagged with ' + 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 }}
|
{{ t }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -642,7 +693,7 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
|
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||||
<div class="mh-100" style="overflow-y: auto;" id="message-page">
|
<div id="message-page" class="mh-100" style="overflow-y: auto">
|
||||||
<template v-if="errorMessage">
|
<template v-if="errorMessage">
|
||||||
<h3 class="text-center my-3">
|
<h3 class="text-center my-3">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
@@ -655,7 +706,11 @@ export default {
|
|||||||
|
|
||||||
<AboutMailpit modals />
|
<AboutMailpit modals />
|
||||||
<AjaxLoader :loading="loading" />
|
<AjaxLoader :loading="loading" />
|
||||||
<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message"
|
<Release
|
||||||
@delete="deleteMessage" />
|
v-if="mailbox.uiConfig.MessageRelay && message"
|
||||||
|
ref="ReleaseRef"
|
||||||
|
:message="message"
|
||||||
|
@delete="deleteMessage"
|
||||||
|
/>
|
||||||
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
|
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script>
|
<script>
|
||||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
import About from "../components/AppAbout.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AboutMailpit,
|
About,
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white">
|
<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white">
|
||||||
<div class="d-block text-center">
|
<div class="d-block text-center">
|
||||||
<RouterLink to="/" class="text-white">
|
<RouterLink to="/" class="text-white">
|
||||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width:80%; width: 100px;">
|
<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width: 80%; width: 100px" />
|
||||||
<p class="h2 my-3">Page not found</p>
|
<p class="h2 my-3">Page not found</p>
|
||||||
|
|
||||||
<p>Click here to continue</p>
|
<p>Click here to continue</p>
|
||||||
@@ -23,7 +23,7 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-none">
|
<div class="d-none">
|
||||||
<AboutMailpit />
|
<About />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
import About from "../components/AppAbout.vue";
|
||||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
import AjaxLoader from "../components/AjaxLoader.vue";
|
||||||
import CommonMixins from '../mixins/CommonMixins'
|
import CommonMixins from "../mixins/CommonMixins";
|
||||||
import ListMessages from '../components/ListMessages.vue'
|
import ListMessages from "../components/ListMessages.vue";
|
||||||
import MessagesMixins from '../mixins/MessagesMixins'
|
import MessagesMixins from "../mixins/MessagesMixins";
|
||||||
import NavSearch from '../components/NavSearch.vue'
|
import NavSearch from "../components/NavSearch.vue";
|
||||||
import NavTags from '../components/NavTags.vue'
|
import NavTags from "../components/NavTags.vue";
|
||||||
import Pagination from '../components/Pagination.vue'
|
import Pagination from "../components/NavPagination.vue";
|
||||||
import SearchForm from '../components/SearchForm.vue'
|
import SearchForm from "../components/SearchForm.vue";
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from "../stores/mailbox";
|
||||||
import { pagination } from '../stores/pagination'
|
import { pagination } from "../stores/pagination";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins, MessagesMixins],
|
|
||||||
|
|
||||||
// global event bus to handle message status changes
|
|
||||||
inject: ["eventBus"],
|
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AboutMailpit,
|
About,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
ListMessages,
|
ListMessages,
|
||||||
NavSearch,
|
NavSearch,
|
||||||
@@ -27,63 +22,68 @@ export default {
|
|||||||
SearchForm,
|
SearchForm,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [CommonMixins, MessagesMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
pagination,
|
pagination,
|
||||||
delayedRefresh: false,
|
delayedRefresh: false,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
$route(to, from) {
|
$route(to, from) {
|
||||||
this.doSearch()
|
this.doSearch();
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
mailbox.searching = this.getSearch()
|
mailbox.searching = this.getSearch();
|
||||||
this.doSearch()
|
this.doSearch();
|
||||||
|
|
||||||
// subscribe to events
|
// subscribe to events
|
||||||
this.eventBus.on("update", this.handleWSUpdate)
|
this.eventBus.on("update", this.handleWSUpdate);
|
||||||
this.eventBus.on("delete", this.handleWSDelete)
|
this.eventBus.on("delete", this.handleWSDelete);
|
||||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
this.eventBus.on("truncate", this.handleWSTruncate);
|
||||||
},
|
},
|
||||||
|
|
||||||
unmounted() {
|
unmounted() {
|
||||||
// unsubscribe from events
|
// unsubscribe from events
|
||||||
this.eventBus.off("update", this.handleWSUpdate)
|
this.eventBus.off("update", this.handleWSUpdate);
|
||||||
this.eventBus.off("delete", this.handleWSDelete)
|
this.eventBus.off("delete", this.handleWSDelete);
|
||||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
this.eventBus.off("truncate", this.handleWSTruncate);
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
doSearch() {
|
doSearch() {
|
||||||
const s = this.getSearch()
|
const s = this.getSearch();
|
||||||
|
|
||||||
if (!s) {
|
if (!s) {
|
||||||
mailbox.searching = false
|
mailbox.searching = false;
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mailbox.searching = s
|
mailbox.searching = s;
|
||||||
|
|
||||||
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
this.apiURI = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
|
||||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||||
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
this.apiURI += "&tz=" + encodeURIComponent(mailbox.timeZone);
|
||||||
}
|
}
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
},
|
},
|
||||||
|
|
||||||
// handler for websocket message updates
|
// handler for websocket message updates
|
||||||
handleWSUpdate(data) {
|
handleWSUpdate(data) {
|
||||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
if (this.mailbox.messages[x].ID == data.ID) {
|
if (this.mailbox.messages[x].ID === data.ID) {
|
||||||
// update message
|
// update message
|
||||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data };
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,52 +92,57 @@ export default {
|
|||||||
handleWSDelete(data) {
|
handleWSDelete(data) {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
if (this.mailbox.messages[x].ID == data.ID) {
|
if (this.mailbox.messages[x].ID === data.ID) {
|
||||||
// remove message from the list
|
// remove message from the list
|
||||||
this.mailbox.messages.splice(x, 1)
|
this.mailbox.messages.splice(x, 1);
|
||||||
removed++
|
removed++;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!removed || this.delayedRefresh) {
|
if (!removed || this.delayedRefresh) {
|
||||||
// nothing changed on this screen, or a refresh is queued, don't refresh
|
// nothing changed on this screen, or a refresh is queued, don't refresh
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||||
this.delayedRefresh = true
|
this.delayedRefresh = true;
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.delayedRefresh = false
|
this.delayedRefresh = false;
|
||||||
this.loadMessages()
|
this.loadMessages();
|
||||||
}, 500)
|
}, 500);
|
||||||
},
|
},
|
||||||
|
|
||||||
// handler for websocket message truncation
|
// handler for websocket message truncation
|
||||||
handleWSTruncate() {
|
handleWSTruncate() {
|
||||||
// all messages deleted, go back to inbox
|
// all messages deleted, go back to inbox
|
||||||
this.$router.push('/')
|
this.$router.push("/");
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
||||||
<div class="col-xl-2 col-md-3 col-auto pe-0">
|
<div class="col-xl-2 col-md-3 col-auto pe-0">
|
||||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
<img :src="resolve('/mailpit.svg')" alt="Mailpit" />
|
||||||
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4k col-lg-5 col-xl-6">
|
<div class="col col-md-4k col-lg-5 col-xl-6">
|
||||||
<SearchForm @loadMessages="loadMessages" />
|
<SearchForm @load-messages="loadMessages" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0">
|
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0">
|
||||||
<div class="float-start d-md-none">
|
<div class="float-start d-md-none">
|
||||||
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
|
<button
|
||||||
data-bs-target="#offcanvas" aria-controls="offcanvas">
|
class="btn btn-outline-light me-2"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#offcanvas"
|
||||||
|
aria-controls="offcanvas"
|
||||||
|
>
|
||||||
<i class="bi bi-list"></i>
|
<i class="bi bi-list"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,41 +150,51 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
|
<div
|
||||||
aria-labelledby="offcanvasLabel">
|
id="offcanvas"
|
||||||
|
class="offcanvas-md offcanvas-start d-md-none"
|
||||||
|
data-bs-scroll="true"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="offcanvasLabel"
|
||||||
|
>
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
|
<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
<button
|
||||||
aria-label="Close"></button>
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="offcanvas"
|
||||||
|
data-bs-target="#offcanvas"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body pb-0">
|
<div class="offcanvas-body pb-0">
|
||||||
<div class="d-flex flex-column h-100">
|
<div class="d-flex flex-column h-100">
|
||||||
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||||
<NavSearch @loadMessages="loadMessages" />
|
<NavSearch @load-messages="loadMessages" />
|
||||||
<NavTags />
|
<NavTags />
|
||||||
</div>
|
</div>
|
||||||
<AboutMailpit />
|
<About />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row flex-fill" style="min-height:0">
|
<div class="row flex-fill" style="min-height: 0">
|
||||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||||
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||||
<NavSearch @loadMessages="loadMessages" />
|
<NavSearch @load-messages="loadMessages" />
|
||||||
<NavTags />
|
<NavTags />
|
||||||
</div>
|
</div>
|
||||||
<AboutMailpit />
|
<About />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
|
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||||
<div class="mh-100" style="overflow-y: auto;" id="message-page">
|
<div id="message-page" class="mh-100" style="overflow-y: auto">
|
||||||
<ListMessages :loading-messages="loading" />
|
<ListMessages :loading-messages="loading" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavSearch @loadMessages="loadMessages" modals />
|
<NavSearch modals @load-messages="loadMessages" />
|
||||||
<AboutMailpit modals />
|
<About modals />
|
||||||
<AjaxLoader :loading="loading" />
|
<AjaxLoader :loading="loading" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user