mirror of
https://github.com/axllent/mailpit.git
synced 2025-07-17 01:32:33 +02: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 pluginVue from 'esbuild-plugin-vue-next'
|
||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
import * as esbuild from "esbuild";
|
||||
import pluginVue from "esbuild-plugin-vue-next";
|
||||
import { sassPlugin } from "esbuild-sass-plugin";
|
||||
|
||||
const doWatch = process.env.WATCH == 'true' ? true : false;
|
||||
const doMinify = process.env.MINIFY == 'true' ? true : false;
|
||||
const doWatch = process.env.WATCH === "true";
|
||||
const doMinify = process.env.MINIFY === "true";
|
||||
|
||||
const ctx = await esbuild.context(
|
||||
{
|
||||
entryPoints: [
|
||||
"server/ui-src/app.js",
|
||||
"server/ui-src/docs.js"
|
||||
],
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
||||
__VUE_OPTIONS_API__: "true",
|
||||
__VUE_PROD_DEVTOOLS__: "false",
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [
|
||||
pluginVue(),
|
||||
sassPlugin({
|
||||
silenceDeprecations: ['import'],
|
||||
silenceDeprecations: ["import"],
|
||||
quietDeps: true,
|
||||
})
|
||||
}),
|
||||
],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info"
|
||||
}
|
||||
)
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
if (doWatch) {
|
||||
await ctx.watch()
|
||||
await ctx.watch();
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
ctx.dispose()
|
||||
await ctx.rebuild();
|
||||
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",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "MINIFY=true node esbuild.config.mjs",
|
||||
"watch": "WATCH=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": {
|
||||
"axios": "^1.2.1",
|
||||
@ -33,6 +36,16 @@
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.25.0",
|
||||
"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>
|
||||
import CommonMixins from './mixins/CommonMixins'
|
||||
import Favicon from './components/Favicon.vue'
|
||||
import AppBadge from './components/AppBadge.vue'
|
||||
import Notifications from './components/Notifications.vue'
|
||||
import EditTags from './components/EditTags.vue'
|
||||
import { mailbox } from "./stores/mailbox"
|
||||
import CommonMixins from "./mixins/CommonMixins";
|
||||
import Favicon from "./components/AppFavicon.vue";
|
||||
import AppBadge from "./components/AppBadge.vue";
|
||||
import Notifications from "./components/AppNotifications.vue";
|
||||
import EditTags from "./components/EditTags.vue";
|
||||
import { mailbox } from "./stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
Favicon,
|
||||
AppBadge,
|
||||
Notifications,
|
||||
EditTags
|
||||
EditTags,
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
// 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
|
||||
}
|
||||
})
|
||||
},
|
||||
mixins: [CommonMixins],
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
// 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>
|
||||
|
||||
<template>
|
||||
|
@ -1,19 +1,19 @@
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue'
|
||||
import mitt from 'mitt';
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { createApp } from "vue";
|
||||
import mitt from "mitt";
|
||||
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
import 'vue-css-donut-chart/src/styles/main.css'
|
||||
import "./assets/styles.scss";
|
||||
import "bootstrap-icons/font/bootstrap-icons.scss";
|
||||
import "bootstrap";
|
||||
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
|
||||
// such as message deletes, updates & truncation.
|
||||
const eventBus = mitt()
|
||||
app.provide('eventBus', eventBus)
|
||||
const eventBus = mitt();
|
||||
app.provide("eventBus", eventBus);
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
|
@ -1,13 +1,16 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Number,
|
||||
loading: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loader" v-if="loading > 0">
|
||||
<div v-if="loading > 0" class="loader">
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="spinner-border text-muted" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
|
@ -1,75 +1,83 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import Settings from '../components/Settings.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import Settings from "./AppSettings.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
Settings,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadInfo() {
|
||||
this.get(this.resolve('/api/v1/info'), false, (response) => {
|
||||
mailbox.appInfo = response.data
|
||||
this.modal('AppInfoModal').show()
|
||||
})
|
||||
this.get(this.resolve("/api/v1/info"), false, (response) => {
|
||||
mailbox.appInfo = response.data;
|
||||
this.modal("AppInfoModal").show();
|
||||
});
|
||||
},
|
||||
|
||||
requestNotifications() {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notifications")
|
||||
alert("This browser does not support desktop notifications");
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
mailbox.notificationsEnabled = true
|
||||
mailbox.notificationsEnabled = true;
|
||||
}
|
||||
|
||||
this.modal('EnableNotificationsModal').hide()
|
||||
})
|
||||
this.modal("EnableNotificationsModal").hide();
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo()">
|
||||
<button class="text-muted btn btn-sm" @click="loadInfo()">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary float-end"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal"
|
||||
title="Mailpit UI settings"
|
||||
>
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled">
|
||||
<button
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled"
|
||||
class="btn btn-sm btn-outline-secondary float-end me-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal"
|
||||
title="Enable browser notifications"
|
||||
>
|
||||
<i class="bi bi-bell"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -77,12 +85,17 @@ export default {
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AppInfoModal"
|
||||
class="modal modal-xl fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" v-if="mailbox.appInfo.RuntimeStats">
|
||||
<div v-if="mailbox.appInfo.RuntimeStats" class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="AppInfoModalLabel">
|
||||
<h5 id="AppInfoModalLabel" class="modal-title">
|
||||
Mailpit
|
||||
<code>({{ mailbox.appInfo.Version }})</code>
|
||||
</h5>
|
||||
@ -92,19 +105,27 @@ export default {
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6">
|
||||
<div v-if="mailbox.appInfo.LatestVersion != 'disabled'">
|
||||
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''">
|
||||
<div v-if="mailbox.appInfo.LatestVersion == ''" class="row g-3">
|
||||
<div class="col">
|
||||
<div class="alert alert-warning mb-3">
|
||||
There might be a newer version available. The check failed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3"
|
||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
|
||||
<div
|
||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
|
||||
class="row g-3"
|
||||
>
|
||||
<div class="col">
|
||||
<a class="btn btn-warning d-block mb-3"
|
||||
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
|
||||
<a
|
||||
class="btn btn-warning d-block mb-3"
|
||||
:href="
|
||||
'https://github.com/axllent/mailpit/releases/tag/' +
|
||||
mailbox.appInfo.LatestVersion
|
||||
"
|
||||
>
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
|
||||
available.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -117,15 +138,21 @@ export default {
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit"
|
||||
target="_blank">
|
||||
<a
|
||||
class="btn btn-primary w-100"
|
||||
href="https://github.com/axllent/mailpit"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="bi bi-github"></i>
|
||||
Github
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://mailpit.axllent.org/docs/"
|
||||
target="_blank">
|
||||
<a
|
||||
class="btn btn-primary w-100"
|
||||
href="https://mailpit.axllent.org/docs/"
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
@ -133,7 +160,8 @@ export default {
|
||||
<div class="card border-secondary text-center">
|
||||
<div class="card-header">Database size</div>
|
||||
<div class="card-body text-muted">
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||
<h5 class="card-title">
|
||||
{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
@ -154,8 +182,7 @@ export default {
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
v-on:click="loadInfo()">
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" @click="loadInfo()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@ -163,46 +190,38 @@ export default {
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Mailpit up since
|
||||
</td>
|
||||
<td>Mailpit up since</td>
|
||||
<td>
|
||||
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Messages deleted
|
||||
</td>
|
||||
<td>Messages deleted</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages accepted
|
||||
</td>
|
||||
<td>SMTP messages accepted</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||
<small class="text-muted">
|
||||
({{
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
getFileSize(
|
||||
mailbox.appInfo.RuntimeStats.SMTPAcceptedSize,
|
||||
)
|
||||
}})
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages rejected
|
||||
</td>
|
||||
<td>SMTP messages rejected</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="mailbox.uiConfig.DuplicatesIgnored">
|
||||
<td>
|
||||
SMTP messages ignored
|
||||
</td>
|
||||
<td>SMTP messages ignored</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}
|
||||
</td>
|
||||
@ -210,12 +229,9 @@ export default {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@ -224,26 +240,30 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1"
|
||||
aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
||||
<div
|
||||
id="EnableNotificationsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="EnableNotificationsModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<h5 id="EnableNotificationsModalLabel" class="modal-title">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click
|
||||
<code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the
|
||||
notifications.
|
||||
<code>enable notifications</code>, and that you must have Mailpit open in a browser tab to
|
||||
be able to receive the notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" v-on:click="requestNotifications">
|
||||
<button type="button" class="btn btn-success" @click="requestNotifications">
|
||||
Enable notifications
|
||||
</button>
|
||||
</div>
|
@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox.js'
|
||||
import { mailbox } from "../stores/mailbox.js";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@ -7,53 +7,51 @@ export default {
|
||||
updating: false,
|
||||
needsUpdate: false,
|
||||
timeout: 500,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
mailboxUnread() {
|
||||
return mailbox.unread
|
||||
}
|
||||
return mailbox.unread;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
mailboxUnread: {
|
||||
handler() {
|
||||
if (this.updating) {
|
||||
this.needsUpdate = true
|
||||
return
|
||||
this.needsUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleUpdate()
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
scheduleUpdate() {
|
||||
this.updating = true
|
||||
this.needsUpdate = false
|
||||
this.updating = true;
|
||||
this.needsUpdate = false;
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.updateAppBadge()
|
||||
this.updating = false
|
||||
this.updateAppBadge();
|
||||
this.updating = false;
|
||||
|
||||
if (this.needsUpdate) {
|
||||
this.scheduleUpdate()
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}, this.timeout)
|
||||
}, this.timeout);
|
||||
},
|
||||
|
||||
updateAppBadge() {
|
||||
if (!('setAppBadge' in navigator)) {
|
||||
return
|
||||
if (!("setAppBadge" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.setAppBadge(this.mailboxUnread)
|
||||
}
|
||||
}
|
||||
}
|
||||
navigator.setAppBadge(this.mailboxUnread);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
|
116
server/ui-src/components/AppFavicon.vue
Normal file
116
server/ui-src/components/AppFavicon.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<script>
|
||||
import { mailbox } from "../stores/mailbox.js";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
favicon: false,
|
||||
iconPath: false,
|
||||
iconTextColor: "#ffffff",
|
||||
iconBgColor: "#dd0000",
|
||||
iconFontSize: 40,
|
||||
iconProcessing: false,
|
||||
iconTimeout: 500,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
count() {
|
||||
let i = mailbox.unread;
|
||||
if (i > 1000) {
|
||||
i = Math.floor(i / 1000) + "k";
|
||||
}
|
||||
|
||||
return i;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
count() {
|
||||
if (!this.favicon || this.iconProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconProcessing = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.icoUpdate();
|
||||
}, this.iconTimeout);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.favicon = document.head.querySelector('link[rel="icon"]');
|
||||
if (this.favicon) {
|
||||
this.iconPath = this.favicon.href;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async icoUpdate() {
|
||||
if (!this.favicon) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.count) {
|
||||
this.iconProcessing = false;
|
||||
this.favicon.href = this.iconPath;
|
||||
return;
|
||||
}
|
||||
|
||||
let fontSize = this.iconFontSize;
|
||||
// Draw badge text
|
||||
let textPaddingX = 7;
|
||||
const textPaddingY = 3;
|
||||
|
||||
const strlen = this.count.toString().length;
|
||||
|
||||
if (strlen > 2) {
|
||||
// if text >= 3 characters then reduce size and padding
|
||||
textPaddingX = 4;
|
||||
fontSize = strlen > 3 ? 30 : 36;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// Draw base icon
|
||||
const icon = new Image();
|
||||
icon.src = this.iconPath;
|
||||
await icon.decode();
|
||||
|
||||
ctx.drawImage(icon, 0, 0, 64, 64);
|
||||
|
||||
// Measure text
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`;
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "top";
|
||||
const textMetrics = ctx.measureText(this.count);
|
||||
|
||||
// Draw badge
|
||||
const paddingX = 7;
|
||||
const paddingY = 4;
|
||||
const cornerRadius = 8;
|
||||
|
||||
const width = textMetrics.width + paddingX * 2;
|
||||
const height = fontSize + paddingY * 2;
|
||||
const x = canvas.width - width;
|
||||
const y = canvas.height - height - 1;
|
||||
|
||||
ctx.fillStyle = this.iconBgColor;
|
||||
ctx.roundRect(x, y, width, height, cornerRadius);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = this.iconTextColor;
|
||||
ctx.fillText(this.count, canvas.width - textPaddingX, canvas.height - fontSize - textPaddingY);
|
||||
|
||||
this.iconProcessing = false;
|
||||
|
||||
this.favicon.href = canvas.toDataURL("image/png");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
289
server/ui-src/components/AppNotifications.vue
Normal file
289
server/ui-src/components/AppNotifications.vue
Normal file
@ -0,0 +1,289 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { Toast } from "bootstrap";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
clientErrors: [], // errors received via websocket
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const d = document.getElementById("app");
|
||||
if (d) {
|
||||
this.version = d.dataset.version;
|
||||
}
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`);
|
||||
|
||||
this.socketBreakReset();
|
||||
this.connect();
|
||||
|
||||
mailbox.notificationsSupported =
|
||||
window.isSecureContext && "Notification" in window && Notification.permission !== "denied";
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission === "granted";
|
||||
|
||||
this.errorNotificationCron();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect() {
|
||||
const ws = new WebSocket(this.socketURI);
|
||||
ws.onmessage = (e) => {
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(e.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type === "new" && response.Data) {
|
||||
this.eventBus.emit("new", response.Data);
|
||||
|
||||
for (const i in response.Data.Tags) {
|
||||
if (
|
||||
mailbox.tags.findIndex((e) => {
|
||||
return e.toLowerCase() === response.Data.Tags[i].toLowerCase();
|
||||
}) < 0
|
||||
) {
|
||||
mailbox.tags.push(response.Data.Tags[i]);
|
||||
mailbox.tags.sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!this.pauseNotifications) {
|
||||
this.pauseNotifications = true;
|
||||
const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]";
|
||||
this.browserNotify("New mail from: " + from, response.Data.Subject);
|
||||
this.setMessageToast(response.Data);
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => {
|
||||
this.pauseNotifications = false;
|
||||
}, 2000);
|
||||
}
|
||||
} else if (response.Type === "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true;
|
||||
mailbox.refresh = true; // trigger refresh
|
||||
window.setTimeout(() => {
|
||||
mailbox.refresh = false;
|
||||
}, 500);
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type === "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total;
|
||||
mailbox.unread = response.Data.Unread;
|
||||
|
||||
// detect version updated, refresh is needed
|
||||
if (this.version !== response.Data.Version) {
|
||||
location.reload();
|
||||
}
|
||||
} else if (response.Type === "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data);
|
||||
} else if (response.Type === "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data);
|
||||
} else if (response.Type === "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate");
|
||||
} else if (response.Type === "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
mailbox.connected = true;
|
||||
this.socketLastConnection = Date.now();
|
||||
if (this.reconnectRefresh) {
|
||||
this.reconnectRefresh = false;
|
||||
mailbox.refresh = true; // trigger refresh
|
||||
window.setTimeout(() => {
|
||||
mailbox.refresh = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (this.socketLastConnection === 0) {
|
||||
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
||||
console.log("Unable to connect to websocket, disabling websocket support");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mailbox.connected) {
|
||||
// count disconnections
|
||||
this.socketBreaks++;
|
||||
}
|
||||
|
||||
// set disconnected state
|
||||
mailbox.connected = false;
|
||||
|
||||
if (this.socketBreaks > 3) {
|
||||
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
||||
// something is not working right on their end, see issue #319
|
||||
console.log("Unstable websocket connection, disabling websocket support");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - this.socketLastConnection > 5000) {
|
||||
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
||||
this.reconnectRefresh = true;
|
||||
} else {
|
||||
this.reconnectRefresh = false;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect(); // reconnect
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
ws.close();
|
||||
};
|
||||
},
|
||||
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0;
|
||||
this.socketBreakReset();
|
||||
}, 15000);
|
||||
},
|
||||
|
||||
browserNotify(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
const options = {
|
||||
body: message,
|
||||
icon: this.resolve("/notification.png"),
|
||||
};
|
||||
|
||||
(() => new Notification(title, options))();
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast(m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastMessage = m;
|
||||
|
||||
const el = document.getElementById("messageToast");
|
||||
if (el) {
|
||||
el.addEventListener("hidden.bs.toast", () => {
|
||||
this.toastMessage = false;
|
||||
});
|
||||
|
||||
Toast.getOrCreateInstance(el).show();
|
||||
}
|
||||
},
|
||||
|
||||
closeToast() {
|
||||
const el = document.getElementById("messageToast");
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide();
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000; // expire after 5s
|
||||
this.clientErrors.push(d);
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
this.errorNotificationCron();
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div
|
||||
v-for="(error, i) in clientErrors"
|
||||
:key="'error_' + i"
|
||||
class="toast show"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="toast-header">
|
||||
<svg
|
||||
class="bd-placeholder-img rounded me-2"
|
||||
width="20"
|
||||
height="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
focusable="false"
|
||||
>
|
||||
<rect width="100%" height="100%" :fill="error.Level === 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div v-if="toastMessage" class="toast-header">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink
|
||||
:to="'/view/' + toastMessage.ID"
|
||||
class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast"
|
||||
>
|
||||
<template v-if="toastMessage.Subject !== ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else> [ no subject ] </template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
381
server/ui-src/components/AppSettings.vue
Normal file
381
server/ui-src/components/AppSettings.vue
Normal file
@ -0,0 +1,381 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import Tags from "bootstrap5-tags";
|
||||
import timezones from "timezones-list";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: localStorage.getItem("theme") ? localStorage.getItem("theme") : "auto",
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(v) {
|
||||
if (v === "auto") {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
localStorage.setItem("theme", v);
|
||||
}
|
||||
this.setTheme();
|
||||
},
|
||||
|
||||
chaosConfig: {
|
||||
handler() {
|
||||
this.chaosUpdated = true;
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
|
||||
"mailbox.skipConfirmations"(v) {
|
||||
if (v) {
|
||||
localStorage.setItem("skip-confirmations", "true");
|
||||
} else {
|
||||
localStorage.removeItem("skip-confirmations");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme();
|
||||
this.$nextTick(() => {
|
||||
Tags.init("select.tz");
|
||||
});
|
||||
|
||||
mailbox.skipConfirmations = !!localStorage.getItem("skip-confirmations");
|
||||
},
|
||||
|
||||
methods: {
|
||||
setTheme() {
|
||||
if (this.theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-bs-theme", this.theme);
|
||||
}
|
||||
},
|
||||
|
||||
loadChaos() {
|
||||
this.get(this.resolve("/api/v1/chaos"), null, (response) => {
|
||||
this.chaosConfig = response.data;
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
saveChaos() {
|
||||
this.put(this.resolve("/api/v1/chaos"), this.chaosConfig, (response) => {
|
||||
this.chaosConfig = response.data;
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="SettingsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="SettingsModalLabel"
|
||||
aria-hidden="true"
|
||||
data-bs-keyboard="false"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 id="SettingsModalLabel" class="modal-title">Mailpit settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul v-if="mailbox.uiConfig.ChaosEnabled" id="myTab" class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
id="ui-tab"
|
||||
class="nav-link active"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#ui-tab-pane"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="ui-tab-pane"
|
||||
aria-selected="true"
|
||||
>
|
||||
Web UI
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
id="chaos-tab"
|
||||
class="nav-link"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#chaos-tab-pane"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="chaos-tab-pane"
|
||||
aria-selected="false"
|
||||
@click="loadChaos"
|
||||
>
|
||||
Chaos
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div
|
||||
id="ui-tab-pane"
|
||||
class="tab-pane fade show active"
|
||||
role="tabpanel"
|
||||
aria-labelledby="ui-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="my-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select id="theme" v-model="theme" class="form-select">
|
||||
<option value="auto">Auto (detect from browser)</option>
|
||||
<option value="light">Light theme</option>
|
||||
<option value="dark">Dark theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||
<select
|
||||
id="timezone"
|
||||
v-model="mailbox.timeZone"
|
||||
class="form-select tz"
|
||||
data-allow-same="true"
|
||||
>
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :key="t" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="tagColors"
|
||||
v-model="mailbox.showTagColors"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="tagColors">
|
||||
Use auto-generated tag colors
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="htmlCheck">
|
||||
Show HTML check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mailbox.uiConfig.SpamAssassin" class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="spamCheck">
|
||||
Show spam check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="skip-confirmations"
|
||||
v-model="mailbox.skipConfirmations"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="skip-confirmations">
|
||||
Skip
|
||||
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
||||
<code>Delete all</code> &
|
||||
</template>
|
||||
<code>Mark all read</code> confirmation dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="mailbox.uiConfig.ChaosEnabled"
|
||||
id="chaos-tab-pane"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="chaos-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<p class="my-3">
|
||||
<b>Chaos</b> allows you to set random SMTP failures and response codes at various stages
|
||||
in a SMTP transaction to test application resilience (<a
|
||||
href="https://mailpit.axllent.org/docs/integration/chaos/"
|
||||
target="_blank"
|
||||
>
|
||||
see documentation </a
|
||||
>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>Response code</code> is the SMTP error code returned by the server if this
|
||||
error is triggered. Error codes must range between 400 and 599.
|
||||
</li>
|
||||
<li>
|
||||
<code>Error probability</code> is the % chance that the error will occur per message
|
||||
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
||||
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
||||
messages received.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="chaosConfig">
|
||||
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Sender</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the sender (From / Sender).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label"> Response code </label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Sender.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Sender.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Recipient</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the recipients (To, Cc, Bcc).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label"> Response code </label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Recipient.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Recipient.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Authentication</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an authentication error response. Note that SMTP authentication must
|
||||
be configured too.
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label"> Response code </label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Authentication.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Authentication.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chaosUpdated" class="mb-3 text-center">
|
||||
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
@ -9,74 +9,83 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
editableTags: [],
|
||||
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/),
|
||||
validTagRe: /^([a-zA-Z0-9\- ._]){1,}$/,
|
||||
tagToDelete: false,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
'mailbox.tags': {
|
||||
"mailbox.tags": {
|
||||
handler(tags) {
|
||||
this.editableTags = []
|
||||
this.editableTags = [];
|
||||
tags.forEach((t) => {
|
||||
this.editableTags.push({ before: t, after: t })
|
||||
})
|
||||
this.editableTags.push({ before: t, after: t });
|
||||
});
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
validTag(t) {
|
||||
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) {
|
||||
return false
|
||||
if (!t.after.match(/^([a-zA-Z0-9\- _.]){1,}$/)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = t.after.toLowerCase()
|
||||
const lower = t.after.toLowerCase();
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) {
|
||||
return false
|
||||
if (this.editableTags[x].before !== t.before && lower === this.editableTags[x].before.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
},
|
||||
|
||||
renameTag(t) {
|
||||
if (!this.validTag(t) || t.before == t.after) {
|
||||
return
|
||||
if (!this.validTag(t) || t.before === t.after) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
|
||||
// the API triggers a reload via websockets
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
deleteTag() {
|
||||
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
|
||||
// the API triggers a reload via websockets
|
||||
this.tagToDelete = false
|
||||
})
|
||||
this.tagToDelete = false;
|
||||
});
|
||||
},
|
||||
|
||||
resetTagEdit(t) {
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) {
|
||||
this.editableTags[x].after = this.editableTags[x].before
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.editableTags[x].before !== t.before &&
|
||||
this.editableTags[x].before !== this.editableTags[x].after
|
||||
) {
|
||||
this.editableTags[x].after = this.editableTags[x].before;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div
|
||||
id="EditTagsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="EditTagsModalLabel"
|
||||
aria-hidden="true"
|
||||
data-bs-keyboard="false"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5>
|
||||
<h5 id="EditTagsModalLabel" class="modal-title">Edit tags</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@ -84,29 +93,34 @@ export default {
|
||||
Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag
|
||||
itself, and not any messages which had the tag.
|
||||
</p>
|
||||
<div class="mb-3" v-for="t in editableTags">
|
||||
<div v-for="(t, i) in editableTags" :key="'tag_' + i" class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''"
|
||||
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required
|
||||
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before"
|
||||
@focus="resetTagEdit(t)">
|
||||
<button v-if="t.before != t.after" class="btn btn-success"
|
||||
@click="renameTag(t)">Save</button>
|
||||
<input
|
||||
v-model.trim="t.after"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:class="!validTag(t) ? 'is-invalid' : ''"
|
||||
aria-describedby="inputGroupPrepend"
|
||||
required
|
||||
@keydown.enter="renameTag(t)"
|
||||
@keydown.esc="t.after = t.before"
|
||||
@focus="resetTagEdit(t)"
|
||||
/>
|
||||
<button v-if="t.before != t.after" class="btn btn-success" @click="renameTag(t)">
|
||||
Save
|
||||
</button>
|
||||
<template v-else>
|
||||
<button class="btn btn-outline-danger"
|
||||
<button
|
||||
class="btn btn-outline-danger"
|
||||
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
|
||||
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false">
|
||||
<template v-if="tagToDelete == t">
|
||||
Confirm?
|
||||
</template>
|
||||
<template v-else>
|
||||
Delete
|
||||
</template>
|
||||
@click="!tagToDelete ? (tagToDelete = t) : deleteTag()"
|
||||
@blur="tagToDelete = false"
|
||||
>
|
||||
<template v-if="tagToDelete == t"> Confirm? </template>
|
||||
<template v-else> Delete </template>
|
||||
</button>
|
||||
</template>
|
||||
<div class="invalid-feedback">
|
||||
Invalid tag name
|
||||
</div>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,122 +0,0 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
favicon: false,
|
||||
iconPath: false,
|
||||
iconTextColor: '#ffffff',
|
||||
iconBgColor: '#dd0000',
|
||||
iconFontSize: 40,
|
||||
iconProcessing: false,
|
||||
iconTimeout: 500,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.favicon = document.head.querySelector('link[rel="icon"]')
|
||||
if (this.favicon) {
|
||||
this.iconPath = this.favicon.href
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
count() {
|
||||
let i = mailbox.unread
|
||||
if (i > 1000) {
|
||||
i = Math.floor(i / 1000) + 'k'
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
count() {
|
||||
if (!this.favicon || this.iconProcessing) {
|
||||
return
|
||||
}
|
||||
|
||||
this.iconProcessing = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.icoUpdate()
|
||||
}, this.iconTimeout)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async icoUpdate() {
|
||||
if (!this.favicon) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.count) {
|
||||
this.iconProcessing = false
|
||||
this.favicon.href = this.iconPath
|
||||
return
|
||||
}
|
||||
|
||||
let fontSize = this.iconFontSize
|
||||
// Draw badge text
|
||||
let textPaddingX = 7
|
||||
let textPaddingY = 3
|
||||
|
||||
let strlen = this.count.toString().length
|
||||
|
||||
if (strlen > 2) {
|
||||
// if text >= 3 characters then reduce size and padding
|
||||
textPaddingX = 4
|
||||
fontSize = strlen > 3 ? 30 : 36
|
||||
}
|
||||
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
|
||||
let ctx = canvas.getContext('2d')
|
||||
|
||||
// Draw base icon
|
||||
let icon = new Image()
|
||||
icon.src = this.iconPath
|
||||
await icon.decode()
|
||||
|
||||
ctx.drawImage(icon, 0, 0, 64, 64)
|
||||
|
||||
// Measure text
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'top'
|
||||
let textMetrics = ctx.measureText(this.count)
|
||||
|
||||
// Draw badge
|
||||
let paddingX = 7
|
||||
let paddingY = 4
|
||||
let cornerRadius = 8
|
||||
|
||||
let width = textMetrics.width + paddingX * 2
|
||||
let height = fontSize + paddingY * 2
|
||||
let x = canvas.width - width
|
||||
let y = canvas.height - height - 1
|
||||
|
||||
ctx.fillStyle = this.iconBgColor
|
||||
ctx.roundRect(x, y, width, height, cornerRadius)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = this.iconTextColor
|
||||
ctx.fillText(
|
||||
this.count,
|
||||
canvas.width - textPaddingX,
|
||||
canvas.height - fontSize - textPaddingY
|
||||
)
|
||||
|
||||
this.iconProcessing = false
|
||||
|
||||
this.favicon.href = canvas.toDataURL("image/png")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template></template>
|
@ -1,135 +1,142 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import dayjs from 'dayjs'
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import dayjs from "dayjs";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
CommonMixins
|
||||
],
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins
|
||||
// use different name to `loading` as that is already in use in CommonMixins
|
||||
loadingMessages: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
const relativeTime = require("dayjs/plugin/relativeTime");
|
||||
dayjs.extend(relativeTime);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refreshUI()
|
||||
this.refreshUI();
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshUI() {
|
||||
window.setTimeout(() => {
|
||||
this.$forceUpdate()
|
||||
this.refreshUI()
|
||||
}, 30000)
|
||||
this.$forceUpdate();
|
||||
this.refreshUI();
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
getRelativeCreated(message) {
|
||||
const d = new Date(message.Created)
|
||||
return dayjs(d).fromNow()
|
||||
const d = new Date(message.Created);
|
||||
return dayjs(d).fromNow();
|
||||
},
|
||||
|
||||
getPrimaryEmailTo(message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
if (message.To && message.To.length > 0) {
|
||||
return message.To[0].Address;
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
return "[ Undisclosed recipients ]";
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
return mailbox.selected.indexOf(id) !== -1;
|
||||
},
|
||||
|
||||
toggleSelected(e, id) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
if (this.isSelected(id)) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||
return ele !== id;
|
||||
});
|
||||
} else {
|
||||
mailbox.selected.push(id)
|
||||
mailbox.selected.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
selectRange(e, id) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
let selecting = false
|
||||
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]
|
||||
if (lastSelected == id) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
return
|
||||
let selecting = false;
|
||||
const lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1];
|
||||
if (lastSelected === id) {
|
||||
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||
return ele !== id;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastSelected === false) {
|
||||
mailbox.selected.push(id)
|
||||
return
|
||||
mailbox.selected.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let d of mailbox.messages) {
|
||||
for (const d of mailbox.messages) {
|
||||
if (selecting) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
mailbox.selected.push(d.ID);
|
||||
}
|
||||
if (d.ID == lastSelected || d.ID == id) {
|
||||
if (d.ID === lastSelected || d.ID === id) {
|
||||
// reached backwards select
|
||||
break
|
||||
break;
|
||||
}
|
||||
} else if (d.ID == id || d.ID == lastSelected) {
|
||||
} else if (d.ID === id || d.ID === lastSelected) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
mailbox.selected.push(d.ID);
|
||||
}
|
||||
selecting = true
|
||||
selecting = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
t = `"${t}"`;
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
q: "tag:" + t,
|
||||
};
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
const params = new URLSearchParams(p);
|
||||
return "/search?" + params.toString();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID"
|
||||
<RouterLink
|
||||
v-for="message in mailbox.messages"
|
||||
:id="message.ID"
|
||||
:key="'message_' + message.ID"
|
||||
:to="'/view/' + message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
|
||||
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
|
||||
@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)"
|
||||
@click.shift="selectRange($event, message.ID)">
|
||||
:class="[message.Read ? 'read' : '', isSelected(message.ID) ? ' selected' : '']"
|
||||
@click.meta="toggleSelected($event, message.ID)"
|
||||
@click.ctrl="toggleSelected($event, message.ID)"
|
||||
@click.shift="selectRange($event, message.ID)"
|
||||
>
|
||||
<div class="col-lg-3">
|
||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
<i v-if="message.Attachments" class="bi bi-paperclip h6 me-1"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div v-if="message.From" class="overflow-x-hidden">
|
||||
@ -142,30 +149,37 @@ export default {
|
||||
<div class="overflow-x-hidden">
|
||||
<div class="text-truncate text-muted small privacy">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
<span v-if="message.To && message.To.length > 1"> [+{{ message.To.length - 1 }}] </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||
<div class="subject text-truncate text-spaces-nowrap">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
<b>{{ message.Subject !== "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div v-if="message.Snippet != ''" class="small text-muted text-truncate">
|
||||
<div v-if="message.Snippet !== ''" class="small text-muted text-truncate">
|
||||
{{ message.Snippet }}
|
||||
</div>
|
||||
<div v-if="message.Tags.length">
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
||||
v-on:click="pagination.start = 0"
|
||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||
:title="'Filter messages tagged with ' + t">
|
||||
<RouterLink
|
||||
v-for="t in message.Tags"
|
||||
:key="t"
|
||||
class="badge me-1"
|
||||
:to="toTagUrl(t)"
|
||||
:style="
|
||||
mailbox.showTagColors
|
||||
? { backgroundColor: colorHash(t) }
|
||||
: { backgroundColor: '#6c757d' }
|
||||
"
|
||||
:title="'Filter messages tagged with ' + t"
|
||||
@click="pagination.start = 0"
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||
<i v-if="message.Attachments" class="bi bi-paperclip float-start h6"></i>
|
||||
{{ getFileSize(message.Size) }}
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
|
||||
@ -176,10 +190,10 @@ export default {
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-center mt-5">
|
||||
<span v-if="loadingMessages > 0" class="text-muted">
|
||||
Loading messages...
|
||||
</span>
|
||||
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
|
||||
<span v-if="loadingMessages > 0" class="text-muted"> Loading messages... </span>
|
||||
<template v-else-if="getSearch()"
|
||||
>No results for <code>{{ getSearch() }}</code></template
|
||||
>
|
||||
<template v-else>No messages in your mailbox</template>
|
||||
</p>
|
||||
</template>
|
||||
|
@ -1,156 +1,193 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from "./AjaxLoader.vue"
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import NavSelected from "../components/NavSelected.vue";
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
reloadInbox() {
|
||||
const paginationParams = this.getPaginationParams()
|
||||
const reload = paginationParams?.start ? false : true
|
||||
const paginationParams = this.getPaginationParams();
|
||||
const reload = !paginationParams?.start;
|
||||
|
||||
this.$router.push('/')
|
||||
this.$router.push("/");
|
||||
if (reload) {
|
||||
// already on first page, reload messages
|
||||
this.loadMessages()
|
||||
this.loadMessages();
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages() {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
this.hideNav(); // hide mobile menu
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.put(this.resolve(`/api/v1/messages`), { read: true }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
deleteAllMessages() {
|
||||
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
|
||||
pagination.start = 0
|
||||
this.loadMessages()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
pagination.start = 0;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
|
||||
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
|
||||
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
|
||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||
<button class="list-group-item list-group-item-action active" @click="reloadInbox">
|
||||
<i v-if="mailbox.connected" class="bi bi-envelope-fill me-1"></i>
|
||||
<i v-else class="bi bi-arrow-clockwise me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
<span
|
||||
v-if="mailbox.unread"
|
||||
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||
title="Unread messages"
|
||||
>
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
@click="markAllRead"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
||||
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.total" @click="deleteAllMessages">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.total"
|
||||
@click="deleteAllMessages"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal"
|
||||
:disabled="!mailbox.total"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
<NavSelected @load-messages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="MarkAllReadModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
|
||||
<h5 id="MarkAllReadModalLabel" class="modal-title">Mark all messages as read?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(mailbox.unread) }}
|
||||
message<span v-if="mailbox.unread > 1">s</span> as read.
|
||||
This will mark {{ formatNumber(mailbox.unread) }} message<span v-if="mailbox.unread > 1"
|
||||
>s</span
|
||||
>
|
||||
as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markAllRead">Confirm</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="DeleteAllModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.total) }}
|
||||
message<span v-if="mailbox.total > 1">s</span>.
|
||||
This will permanently delete {{ formatNumber(mailbox.total) }} message<span
|
||||
v-if="mailbox.total > 1"
|
||||
>s</span
|
||||
>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
120
server/ui-src/components/NavPagination.vue
Normal file
120
server/ui-src/components/NavPagination.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { limitOptions, pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
limitOptions,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev() {
|
||||
return pagination.start > 0;
|
||||
},
|
||||
|
||||
canNext() {
|
||||
return this.total > pagination.start + mailbox.messages.length;
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages() {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10);
|
||||
if (t > this.total) {
|
||||
t = this.total;
|
||||
}
|
||||
|
||||
return t;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit() {
|
||||
pagination.start = 0;
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
viewNext() {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10);
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
viewPrev() {
|
||||
let s = pagination.start - pagination.limit;
|
||||
if (s < 0) {
|
||||
s = 0;
|
||||
}
|
||||
pagination.start = s;
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
updateQueryParams() {
|
||||
const path = this.$route.path;
|
||||
const p = {
|
||||
...this.$route.query,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString();
|
||||
} else {
|
||||
delete p.start;
|
||||
}
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
} else {
|
||||
delete p.limit;
|
||||
}
|
||||
const params = new URLSearchParams(p);
|
||||
this.$router.push(path + "?" + params.toString());
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<select
|
||||
v-model="pagination.limit"
|
||||
class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0"
|
||||
@change="changeLimit"
|
||||
>
|
||||
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-light ms-2 me-1"
|
||||
:disabled="!canPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'"
|
||||
@click="viewPrev"
|
||||
>
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-light"
|
||||
:disabled="!canNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'"
|
||||
@click="viewNext"
|
||||
>
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
@ -1,79 +1,79 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import NavSelected from "../components/NavSelected.vue";
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
this.hideNav(); // hide mobile menu
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
deleteAllMessages() {
|
||||
const s = this.getSearch()
|
||||
const s = this.getSearch();
|
||||
if (!s) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
uri += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
let uri = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
|
||||
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||
uri += "&tz=" + encodeURIComponent(mailbox.timeZone);
|
||||
}
|
||||
|
||||
this.delete(uri, false, () => {
|
||||
this.$router.push('/')
|
||||
})
|
||||
this.$router.push("/");
|
||||
});
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
const s = this.getSearch()
|
||||
const s = this.getSearch();
|
||||
if (!s) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = this.resolve(`/api/v1/messages`)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
uri += '?tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
let uri = this.resolve(`/api/v1/messages`);
|
||||
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||
uri += "?tz=" + encodeURIComponent(mailbox.timeZone);
|
||||
}
|
||||
|
||||
this.put(uri, { 'read': true, "search": s }, () => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.put(uri, { read: true, search: s }, () => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
|
||||
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
@ -83,83 +83,121 @@ export default {
|
||||
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
<span
|
||||
v-if="mailbox.unread"
|
||||
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||
title="Unread messages"
|
||||
>
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
@click="markAllRead"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal"
|
||||
:disabled="!mailbox.messages_unread"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
||||
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
@click="deleteAllMessages" :disabled="!mailbox.count">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.count"
|
||||
@click="deleteAllMessages"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal"
|
||||
:disabled="!mailbox.count"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
<NavSelected @load-messages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="MarkAllReadModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all search results as read?</h5>
|
||||
<h5 id="MarkAllReadModalLabel" class="modal-title">Mark all search results as read?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(mailbox.messages_unread) }}
|
||||
message<span v-if="mailbox.messages_unread > 1">s</span>
|
||||
This will mark {{ formatNumber(mailbox.messages_unread) }} message<span
|
||||
v-if="mailbox.messages_unread > 1"
|
||||
>s</span
|
||||
>
|
||||
matching <code>{{ getSearch() }}</code>
|
||||
as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markAllRead">Confirm</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="DeleteAllModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages matching search?</h5>
|
||||
<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages matching search?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }}
|
||||
message<span v-if="mailbox.count > 1">s</span> matching
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }} message<span
|
||||
v-if="mailbox.count > 1"
|
||||
>s</span
|
||||
>
|
||||
matching
|
||||
<code>{{ getSearch() }}</code>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,114 +1,120 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.$emit('loadMessages')
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
// mark selected messages as read
|
||||
markSelectedRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.put(this.resolve(`/api/v1/messages`), { Read: true, IDs: mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
return mailbox.selected.indexOf(id) !== -1;
|
||||
},
|
||||
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.put(this.resolve(`/api/v1/messages`), { Read: false, IDs: mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages() {
|
||||
let ids = []
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected))
|
||||
let ids = [];
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected));
|
||||
if (!ids.length) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.delete(this.resolve(`/api/v1/messages`), { IDs: ids }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
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) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
|
||||
// test of any selected emails are read
|
||||
selectedHasRead() {
|
||||
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) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
|
||||
v-on:click="markSelectedRead">
|
||||
<button
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!selectedHasUnread()"
|
||||
@click="markSelectedRead"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark read
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
|
||||
v-on:click="markSelectedUnread">
|
||||
<button
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!selectedHasRead()"
|
||||
@click="markSelectedUnread"
|
||||
>
|
||||
<i class="bi bi-eye-slash me-1"></i>
|
||||
Mark unread
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages()">
|
||||
<button class="list-group-item list-group-item-action" @click="deleteMessages()">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []">
|
||||
<button class="list-group-item list-group-item-action" @click="mailbox.selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancel selection
|
||||
</button>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
@ -10,79 +10,77 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// test whether a tag is currently being searched for (in the URL)
|
||||
inSearch(tag) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const query = urlParams.get('q')
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const query = urlParams.get("q");
|
||||
if (!query) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i')
|
||||
return query.match(re)
|
||||
const re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, "i");
|
||||
return query.match(re);
|
||||
},
|
||||
|
||||
// toggle a tag search in the search URL, add or remove it accordingly
|
||||
toggleTag(e, tag) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
let query = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let query = urlParams.get("q") ? urlParams.get("q") : "";
|
||||
|
||||
let re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, 'i')
|
||||
const re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, "i");
|
||||
|
||||
if (query.match(re)) {
|
||||
// remove is exists
|
||||
query = query.replace(re, '$1$4')
|
||||
query = query.replace(re, "$1$4");
|
||||
} else {
|
||||
// add to query
|
||||
if (tag.match(/ /)) {
|
||||
tag = `"${tag}"`
|
||||
tag = `"${tag}"`;
|
||||
}
|
||||
query = query + " tag:" + tag
|
||||
query = query + " tag:" + tag;
|
||||
}
|
||||
|
||||
query = query.trim()
|
||||
query = query.trim();
|
||||
|
||||
if (query == '') {
|
||||
this.$router.push('/')
|
||||
if (query === "") {
|
||||
this.$router.push("/");
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
start: pagination.start.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
})
|
||||
this.$router.push('/search?' + params.toString())
|
||||
});
|
||||
this.$router.push("/search?" + params.toString());
|
||||
}
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
t = `"${t}"`;
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
q: "tag:" + t,
|
||||
};
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
const params = new URLSearchParams(p);
|
||||
return "/search?" + params.toString();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||
<div class="mt-4 text-muted">
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Tags
|
||||
</button>
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Tags</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
|
||||
@ -99,12 +97,20 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
<div class="list-group mt-1 mb-2">
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav"
|
||||
@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)"
|
||||
<RouterLink
|
||||
v-for="tag in mailbox.tags"
|
||||
:key="tag"
|
||||
:to="toTagUrl(tag)"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||
<i class="bi bi-tag" v-else></i>
|
||||
class="list-group-item list-group-item-action small px-2"
|
||||
:class="inSearch(tag) ? 'active' : ''"
|
||||
@click.exact="hideNav"
|
||||
@click="pagination.start = 0"
|
||||
@click.meta="toggleTag($event, tag)"
|
||||
@click.ctrl="toggleTag($event, tag)"
|
||||
>
|
||||
<i v-if="inSearch(tag)" class="bi bi-tag-fill"></i>
|
||||
<i v-else class="bi bi-tag"></i>
|
||||
{{ tag }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
@ -1,263 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { Toast } from 'bootstrap'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
clientErrors: [], // errors received via websocket
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const d = document.getElementById('app')
|
||||
if (d) {
|
||||
this.version = d.dataset.version
|
||||
}
|
||||
|
||||
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
|
||||
|
||||
this.socketBreakReset()
|
||||
this.connect()
|
||||
|
||||
mailbox.notificationsSupported = window.isSecureContext
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
|
||||
this.errorNotificationCron()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect() {
|
||||
const ws = new WebSocket(this.socketURI)
|
||||
ws.onmessage = (e) => {
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(e.data)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
this.eventBus.emit("new", response.Data)
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
|
||||
mailbox.tags.push(response.Data.Tags[i])
|
||||
mailbox.tags.sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!this.pauseNotifications) {
|
||||
this.pauseNotifications = true
|
||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
|
||||
this.browserNotify("New mail from: " + from, response.Data.Subject)
|
||||
this.setMessageToast(response.Data)
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
|
||||
}
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
mailbox.unread = response.Data.Unread
|
||||
|
||||
// detect version updated, refresh is needed
|
||||
if (this.version != response.Data.Version) {
|
||||
location.reload()
|
||||
}
|
||||
} else if (response.Type == "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data)
|
||||
} else if (response.Type == "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data)
|
||||
} else if (response.Type == "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate")
|
||||
} else if (response.Type == "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
mailbox.connected = true
|
||||
this.socketLastConnection = Date.now()
|
||||
if (this.reconnectRefresh) {
|
||||
this.reconnectRefresh = false
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (this.socketLastConnection == 0) {
|
||||
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
||||
console.log('Unable to connect to websocket, disabling websocket support')
|
||||
return
|
||||
}
|
||||
|
||||
if (mailbox.connected) {
|
||||
// count disconnections
|
||||
this.socketBreaks++
|
||||
}
|
||||
|
||||
// set disconnected state
|
||||
mailbox.connected = false
|
||||
|
||||
if (this.socketBreaks > 3) {
|
||||
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
||||
// something is not working right on their end, see issue #319
|
||||
console.log('Unstable websocket connection, disabling websocket support')
|
||||
return
|
||||
}
|
||||
if (Date.now() - this.socketLastConnection > 5000) {
|
||||
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
||||
this.reconnectRefresh = true
|
||||
} else {
|
||||
this.reconnectRefresh = false
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect() // reconnect
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
ws.onerror = function (err) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0
|
||||
this.socketBreakReset()
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
browserNotify(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let options = {
|
||||
body: message,
|
||||
icon: this.resolve('/notification.png')
|
||||
}
|
||||
new Notification(title, options)
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast(m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
this.toastMessage = m
|
||||
|
||||
const el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
el.addEventListener('hidden.bs.toast', () => {
|
||||
this.toastMessage = false
|
||||
})
|
||||
|
||||
Toast.getOrCreateInstance(el).show()
|
||||
}
|
||||
},
|
||||
|
||||
closeToast() {
|
||||
const el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide()
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000 // expire after 5s
|
||||
this.clientErrors.push(d)
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1)
|
||||
}
|
||||
})
|
||||
this.errorNotificationCron()
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
|
||||
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" v-if="toastMessage">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast">
|
||||
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else>
|
||||
[ no subject ]
|
||||
</template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,107 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { limitOptions, pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: Number,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
limitOptions,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev() {
|
||||
return pagination.start > 0
|
||||
},
|
||||
|
||||
canNext() {
|
||||
return this.total > (pagination.start + mailbox.messages.length)
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages() {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10)
|
||||
if (t > this.total) {
|
||||
t = this.total
|
||||
}
|
||||
|
||||
return t
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit() {
|
||||
pagination.start = 0
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
viewNext() {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
viewPrev() {
|
||||
let s = pagination.start - pagination.limit
|
||||
if (s < 0) {
|
||||
s = 0
|
||||
}
|
||||
pagination.start = s
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
updateQueryParams() {
|
||||
const path = this.$route.path
|
||||
const p = {
|
||||
...this.$route.query
|
||||
}
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
} else {
|
||||
delete p.start
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
} else {
|
||||
delete p.limit
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push(path + '?' + params.toString())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0">
|
||||
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
@ -1,78 +1,84 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.searchFromURL()
|
||||
search: "",
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route() {
|
||||
this.searchFromURL()
|
||||
}
|
||||
this.searchFromURL();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.searchFromURL();
|
||||
},
|
||||
|
||||
methods: {
|
||||
searchFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
this.search = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.search = urlParams.get("q") ? urlParams.get("q") : "";
|
||||
},
|
||||
|
||||
doSearch(e) {
|
||||
pagination.start = 0
|
||||
if (this.search == '') {
|
||||
this.$router.push('/')
|
||||
pagination.start = 0;
|
||||
if (this.search === "") {
|
||||
this.$router.push("/");
|
||||
} else {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const curr = urlParams.get('q')
|
||||
if (curr && curr == this.search) {
|
||||
pagination.start = 0
|
||||
this.$emit('loadMessages')
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const curr = urlParams.get("q");
|
||||
if (curr && curr === this.search) {
|
||||
pagination.start = 0;
|
||||
this.$emit("loadMessages");
|
||||
}
|
||||
const p = {
|
||||
q: this.search
|
||||
}
|
||||
q: this.search,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
p.start = pagination.start.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push('/search?' + params.toString())
|
||||
const params = new URLSearchParams(p);
|
||||
this.$router.push("/search?" + params.toString());
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
resetSearch() {
|
||||
this.search = ''
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
this.search = "";
|
||||
this.$router.push("/");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-on:submit="doSearch">
|
||||
<form @submit="doSearch">
|
||||
<div class="input-group flex-nowrap">
|
||||
<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
|
||||
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
|
||||
placeholder="Search mailbox">
|
||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''"
|
||||
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||
<input
|
||||
v-model.trim="search"
|
||||
type="text"
|
||||
class="form-control border-0"
|
||||
aria-label="Search"
|
||||
placeholder="Search mailbox"
|
||||
/>
|
||||
<span v-if="search != ''" class="btn btn-link position-absolute end-0 text-muted" @click="resetSearch"
|
||||
><i class="bi bi-x-circle"></i
|
||||
></span>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" type="submit">
|
||||
<i class="bi bi-search"></i>
|
||||
|
@ -1,295 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import Tags from 'bootstrap5-tags'
|
||||
import timezones from 'timezones-list'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(v) {
|
||||
if (v == 'auto') {
|
||||
localStorage.removeItem('theme')
|
||||
} else {
|
||||
localStorage.setItem('theme', v)
|
||||
}
|
||||
this.setTheme()
|
||||
},
|
||||
|
||||
chaosConfig: {
|
||||
handler() {
|
||||
this.chaosUpdated = true
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
||||
'mailbox.skipConfirmations'(v) {
|
||||
if (v) {
|
||||
localStorage.setItem('skip-confirmations', 'true')
|
||||
} else {
|
||||
localStorage.removeItem('skip-confirmations')
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme()
|
||||
this.$nextTick(function () {
|
||||
Tags.init('select.tz')
|
||||
})
|
||||
|
||||
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
|
||||
},
|
||||
|
||||
methods: {
|
||||
setTheme() {
|
||||
if (
|
||||
this.theme === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', this.theme)
|
||||
}
|
||||
},
|
||||
|
||||
loadChaos() {
|
||||
this.get(this.resolve('/api/v1/chaos'), null, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
saveChaos() {
|
||||
this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="ui-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane"
|
||||
aria-selected="true">Web UI</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chaos-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane"
|
||||
aria-selected="false" @click="loadChaos">Chaos</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab"
|
||||
tabindex="0">
|
||||
<div class="my-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select class="form-select" v-model="theme" id="theme">
|
||||
<option value="auto">Auto (detect from browser)</option>
|
||||
<option value="light">Light theme</option>
|
||||
<option value="dark">Dark theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone"
|
||||
data-allow-same="true">
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
|
||||
v-model="mailbox.showTagColors">
|
||||
<label class="form-check-label" for="tagColors">
|
||||
Use auto-generated tag colors
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck">
|
||||
<label class="form-check-label" for="htmlCheck">
|
||||
Show HTML check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck">
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck">
|
||||
<label class="form-check-label" for="spamCheck">
|
||||
Show spam check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="skip-confirmations" v-model="mailbox.skipConfirmations">
|
||||
<label class="form-check-label" for="skip-confirmations">
|
||||
Skip
|
||||
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
||||
<code>Delete all</code> &
|
||||
</template>
|
||||
<code>Mark all read</code> confirmation dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab"
|
||||
tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<p class="my-3">
|
||||
<b>Chaos</b> allows you to set random SMTP failures and response codes at various
|
||||
stages in a SMTP transaction to test application resilience
|
||||
(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank">
|
||||
see documentation
|
||||
</a>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>Response code</code> is the SMTP error code returned by the server if this
|
||||
error is triggered. Error codes must range between 400 and 599.
|
||||
</li>
|
||||
<li>
|
||||
<code>Error probability</code> is the % chance that the error will occur per message
|
||||
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
||||
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
||||
messages received.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="chaosConfig">
|
||||
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Sender</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the sender (From / Sender).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Sender.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Recipient</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the recipients (To, Cc, Bcc).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Recipient.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Authentication</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an authentication error response.
|
||||
Note that SMTP authentication must be configured too.
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Authentication.ErrorCode" min="400"
|
||||
max="599" required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Authentication.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chaosUpdated" class="mb-3 text-center">
|
||||
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,221 +1,234 @@
|
||||
<script>
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
import { VcDonut } from "vue-css-donut-chart";
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
check: false,
|
||||
platforms: [],
|
||||
allPlatforms: {
|
||||
"windows": "Windows",
|
||||
windows: "Windows",
|
||||
"windows-mail": "Windows Mail",
|
||||
"outlook-com": "Outlook.com",
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
"android": "Android",
|
||||
macos: "macOS",
|
||||
ios: "iOS",
|
||||
android: "Android",
|
||||
"desktop-webmail": "Desktop Webmail",
|
||||
"mobile-webmail": "Mobile Webmail",
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConfig()
|
||||
this.doCheck()
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
summary() {
|
||||
if (!this.check) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = {
|
||||
const result = {
|
||||
Warnings: [],
|
||||
Total: {
|
||||
Nodes: this.check.Total.Nodes
|
||||
}
|
||||
}
|
||||
Nodes: this.check.Total.Nodes,
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.check.Warnings.length; i++) {
|
||||
let o = JSON.parse(JSON.stringify(this.check.Warnings[i]))
|
||||
const o = JSON.parse(JSON.stringify(this.check.Warnings[i]));
|
||||
|
||||
// for <script> test
|
||||
if (o.Results.length == 0) {
|
||||
result.Warnings.push(o)
|
||||
continue
|
||||
if (o.Results.length === 0) {
|
||||
result.Warnings.push(o);
|
||||
continue;
|
||||
}
|
||||
|
||||
// filter by enabled platforms
|
||||
let results = o.Results.filter((w) => {
|
||||
return this.platforms.indexOf(w.Platform) != -1
|
||||
})
|
||||
const results = o.Results.filter((w) => {
|
||||
return this.platforms.indexOf(w.Platform) !== -1;
|
||||
});
|
||||
|
||||
if (results.length == 0) {
|
||||
continue
|
||||
if (results.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// recalculate the percentages
|
||||
let y = 0, p = 0, n = 0
|
||||
let y = 0;
|
||||
let p = 0;
|
||||
let n = 0;
|
||||
|
||||
results.forEach(function (r) {
|
||||
if (r.Support == "yes") {
|
||||
y++
|
||||
} else if (r.Support == "partial") {
|
||||
p++
|
||||
results.forEach((r) => {
|
||||
if (r.Support === "yes") {
|
||||
y++;
|
||||
} else if (r.Support === "partial") {
|
||||
p++;
|
||||
} else {
|
||||
n++
|
||||
n++;
|
||||
}
|
||||
})
|
||||
let total = y + p + n
|
||||
o.Results = results
|
||||
});
|
||||
const total = y + p + n;
|
||||
o.Results = results;
|
||||
o.Score = {
|
||||
Found: o.Score.Found,
|
||||
Supported: y / total * 100,
|
||||
Partial: p / total * 100,
|
||||
Unsupported: n / total * 100
|
||||
Supported: (y / total) * 100,
|
||||
Partial: (p / total) * 100,
|
||||
Unsupported: (n / total) * 100,
|
||||
};
|
||||
|
||||
result.Warnings.push(o);
|
||||
}
|
||||
|
||||
result.Warnings.push(o)
|
||||
}
|
||||
|
||||
let maxPartial = 0, maxUnsupported = 0
|
||||
let maxPartial = 0;
|
||||
let maxUnsupported = 0;
|
||||
result.Warnings.forEach((w) => {
|
||||
let scoreWeight = 1
|
||||
let scoreWeight = 1;
|
||||
if (w.Score.Found < result.Total.Nodes) {
|
||||
// each error is weighted based on the number of occurrences vs: the total message nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes;
|
||||
}
|
||||
|
||||
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
|
||||
// are actually used in the HTML, and including things like bootstrap styles completely throws
|
||||
// off the calculation as these dominate.
|
||||
if (this.isPseudoClassOrAtRule(w.Title)) {
|
||||
scoreWeight = 0.05
|
||||
w.PseudoClassOrAtRule = true
|
||||
scoreWeight = 0.05;
|
||||
w.PseudoClassOrAtRule = true;
|
||||
}
|
||||
|
||||
let scorePartial = w.Score.Partial * scoreWeight
|
||||
let scoreUnsupported = w.Score.Unsupported * scoreWeight
|
||||
const scorePartial = w.Score.Partial * scoreWeight;
|
||||
const scoreUnsupported = w.Score.Unsupported * scoreWeight;
|
||||
if (scorePartial > maxPartial) {
|
||||
maxPartial = scorePartial
|
||||
maxPartial = scorePartial;
|
||||
}
|
||||
if (scoreUnsupported > maxUnsupported) {
|
||||
maxUnsupported = scoreUnsupported
|
||||
maxUnsupported = scoreUnsupported;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// sort warnings by final score
|
||||
result.Warnings.sort((a, b) => {
|
||||
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
|
||||
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
|
||||
let aWeight =
|
||||
a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes;
|
||||
let bWeight =
|
||||
b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes;
|
||||
|
||||
if (this.isPseudoClassOrAtRule(a.Title)) {
|
||||
aWeight = 0.05
|
||||
aWeight = 0.05;
|
||||
}
|
||||
|
||||
if (this.isPseudoClassOrAtRule(b.Title)) {
|
||||
bWeight = 0.05
|
||||
bWeight = 0.05;
|
||||
}
|
||||
|
||||
return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
})
|
||||
return (
|
||||
(a.Score.Unsupported + a.Score.Partial) * aWeight <
|
||||
(b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
);
|
||||
});
|
||||
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported
|
||||
result.Total.Partial = maxPartial
|
||||
result.Total.Unsupported = maxUnsupported
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported;
|
||||
result.Total.Partial = maxPartial;
|
||||
result.Total.Unsupported = maxUnsupported;
|
||||
|
||||
this.$emit('setHtmlScore', result.Total.Supported)
|
||||
this.$emit("setHtmlScore", result.Total.Supported);
|
||||
|
||||
return result
|
||||
return result;
|
||||
},
|
||||
|
||||
graphSections() {
|
||||
let s = Math.round(this.summary.Total.Supported)
|
||||
let p = Math.round(this.summary.Total.Partial)
|
||||
let u = 100 - s - p
|
||||
const s = Math.round(this.summary.Total.Supported);
|
||||
const p = Math.round(this.summary.Total.Partial);
|
||||
const u = 100 - s - p;
|
||||
return [
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Supported) + '% supported',
|
||||
label: this.round2dm(this.summary.Total.Supported) + "% supported",
|
||||
value: s,
|
||||
color: '#198754'
|
||||
color: "#198754",
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Partial) + '% partially supported',
|
||||
label: this.round2dm(this.summary.Total.Partial) + "% partially supported",
|
||||
value: p,
|
||||
color: '#ffc107'
|
||||
color: "#ffc107",
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + '% not supported',
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + "% not supported",
|
||||
value: u,
|
||||
color: '#dc3545'
|
||||
}
|
||||
]
|
||||
color: "#dc3545",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// colors depend on both varying unsupported & partially unsupported percentages
|
||||
scoreColor() {
|
||||
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
|
||||
this.$emit('setBadgeStyle', 'bg-success')
|
||||
return 'text-success'
|
||||
this.$emit("setBadgeStyle", "bg-success");
|
||||
return "text-success";
|
||||
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
|
||||
this.$emit('setBadgeStyle', 'bg-warning text-primary')
|
||||
return 'text-warning'
|
||||
this.$emit("setBadgeStyle", "bg-warning text-primary");
|
||||
return "text-warning";
|
||||
}
|
||||
|
||||
this.$emit('setBadgeStyle', 'bg-danger')
|
||||
return 'text-danger'
|
||||
}
|
||||
this.$emit("setBadgeStyle", "bg-danger");
|
||||
return "text-danger";
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setHtmlScore', false)
|
||||
this.doCheck()
|
||||
this.$emit("setHtmlScore", false);
|
||||
this.doCheck();
|
||||
},
|
||||
deep: true
|
||||
deep: true,
|
||||
},
|
||||
platforms(v) {
|
||||
localStorage.setItem('html-check-platforms', JSON.stringify(v))
|
||||
localStorage.setItem("html-check-platforms", JSON.stringify(v));
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConfig();
|
||||
this.doCheck();
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.check = false;
|
||||
|
||||
if (this.message.HTML == "") {
|
||||
return
|
||||
if (this.message.HTML === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null)
|
||||
axios
|
||||
.get(this.resolve("/api/v1/message/" + this.message.ID + "/html-check"), null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
|
||||
// set tooltips
|
||||
window.setTimeout(() => {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
}, 500)
|
||||
[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
|
||||
}, 500);
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@ -223,68 +236,72 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
loadConfig() {
|
||||
let platforms = localStorage.getItem('html-check-platforms')
|
||||
const platforms = localStorage.getItem("html-check-platforms");
|
||||
if (platforms) {
|
||||
try {
|
||||
this.platforms = JSON.parse(platforms)
|
||||
} catch (e) {
|
||||
}
|
||||
this.platforms = JSON.parse(platforms);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// set all options
|
||||
if (this.platforms.length == 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms)
|
||||
if (this.platforms.length === 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms);
|
||||
}
|
||||
},
|
||||
|
||||
// return a platform's families (email clients)
|
||||
families(k) {
|
||||
if (this.check.Platforms[k]) {
|
||||
return this.check.Platforms[k]
|
||||
return this.check.Platforms[k];
|
||||
}
|
||||
|
||||
return []
|
||||
return [];
|
||||
},
|
||||
|
||||
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
|
||||
isPseudoClassOrAtRule(t) {
|
||||
return t.match(/^(:|@)/)
|
||||
return t.match(/^(:|@)/);
|
||||
},
|
||||
|
||||
round(v) {
|
||||
return Math.round(v)
|
||||
return Math.round(v);
|
||||
},
|
||||
|
||||
round2dm(v) {
|
||||
return Math.round(v * 100) / 100
|
||||
return Math.round(v * 100) / 100;
|
||||
},
|
||||
|
||||
scrollToWarnings() {
|
||||
if (!this.$refs.warnings) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" })
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
sanitizeHTML(html) {
|
||||
return DOMPurify.sanitize(html);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -299,39 +316,50 @@ export default {
|
||||
<div class="mt-5 mb-3">
|
||||
<div class="row w-100">
|
||||
<div class="col-md-8">
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
|
||||
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
|
||||
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
|
||||
<vc-donut
|
||||
:sections="graphSections"
|
||||
background="var(--bs-body-bg)"
|
||||
:size="180"
|
||||
unit="px"
|
||||
:thickness="20"
|
||||
has-legend
|
||||
legend-placement="bottom"
|
||||
:total="100"
|
||||
:start-angle="0"
|
||||
:auto-adjust-text-size="true"
|
||||
@section-click="scrollToWarnings"
|
||||
>
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ round2dm(summary.Total.Supported) }}%
|
||||
</h2>
|
||||
<div class="text-body">
|
||||
support
|
||||
</div>
|
||||
<div class="text-body">support</div>
|
||||
<template #legend>
|
||||
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-success"></i>
|
||||
{{ round2dm(summary.Total.Supported) }}% supported
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-warning"></i>
|
||||
{{ round2dm(summary.Total.Partial) }}% partially supported
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-danger"></i>
|
||||
{{ round2dm(summary.Total.Unsupported) }}% not supported
|
||||
</span>
|
||||
</p>
|
||||
<p class="small text-muted">
|
||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
||||
</p>
|
||||
<p class="small text-muted">calculated from {{ formatNumber(check.Total.Tests) }} tests</p>
|
||||
</template>
|
||||
</vc-donut>
|
||||
|
||||
<div class="input-group justify-content-center mb-3">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults"
|
||||
>
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
@ -339,12 +367,24 @@ export default {
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<h2 class="h5 mb-3">Tested platforms:</h2>
|
||||
<div class="form-check form-switch" v-for="p, k in allPlatforms">
|
||||
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms"
|
||||
:aria-label="p" :id="'Check_' + k">
|
||||
<label class="form-check-label" :for="'Check_' + k"
|
||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'" :title="families(k).join(', ')"
|
||||
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
|
||||
<div v-for="(p, k) in allPlatforms" :key="'check_' + k" class="form-check form-switch">
|
||||
<input
|
||||
:id="'Check_' + k"
|
||||
v-model="platforms"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
:value="k"
|
||||
:aria-label="p"
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
:for="'Check_' + k"
|
||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'"
|
||||
:title="families(k).join(', ')"
|
||||
data-bs-toggle="tooltip"
|
||||
:data-bs-title="families(k).join(', ')"
|
||||
>
|
||||
{{ p }}
|
||||
</label>
|
||||
</div>
|
||||
@ -356,45 +396,72 @@ export default {
|
||||
<h4 ref="warnings" class="h5 mt-4">
|
||||
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
|
||||
</h4>
|
||||
<div class="accordion" id="warnings">
|
||||
<div class="accordion-item" v-for="warning in summary.Warnings">
|
||||
<div id="warnings" class="accordion">
|
||||
<div v-for="(warning, i) in summary.Warnings" :key="'warning_' + i" class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug"
|
||||
aria-expanded="false"
|
||||
:aria-controls="warning.Slug"
|
||||
>
|
||||
<div class="row w-100 w-lg-75">
|
||||
<div class="col-sm">
|
||||
{{ warning.Title }}
|
||||
<span class="ms-2 small badge text-bg-secondary" title="Test category">
|
||||
{{ warning.Category }}
|
||||
</span>
|
||||
<span class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected">
|
||||
<span
|
||||
class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected"
|
||||
>
|
||||
x {{ warning.Score.Found }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm mt-2 mt-sm-0">
|
||||
<div class="progress-stacked">
|
||||
<div class="progress" role="progressbar" aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported" aria-valuemin="0"
|
||||
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }"
|
||||
title="Supported">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Supported + '%' }"
|
||||
title="Supported"
|
||||
>
|
||||
<div class="progress-bar bg-success">
|
||||
{{ round(warning.Score.Supported) + '%' }}
|
||||
{{ round(warning.Score.Supported) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }" title="Partial support">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }"
|
||||
title="Partial support"
|
||||
>
|
||||
<div class="progress-bar progress-bar-striped bg-warning text-dark">
|
||||
{{ round(warning.Score.Partial) + '%' }}
|
||||
{{ round(warning.Score.Partial) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0"
|
||||
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }"
|
||||
title="Not supported">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Unsupported + '%' }"
|
||||
title="Not supported"
|
||||
>
|
||||
<div class="progress-bar bg-danger">
|
||||
{{ round(warning.Score.Unsupported) + '%' }}
|
||||
{{ round(warning.Score.Unsupported) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -404,28 +471,45 @@ export default {
|
||||
</h2>
|
||||
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
|
||||
<div class="accordion-body">
|
||||
<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule">
|
||||
<p v-if="warning.Description !== '' || warning.PseudoClassOrAtRule">
|
||||
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
|
||||
propert<template v-if="warning.Score.Found === 1">y</template><template
|
||||
v-else>ies</template> in the CSS
|
||||
styles, but unable to test if used or not.
|
||||
<template v-if="warning.Score.Found === 1">property</template>
|
||||
<template v-else>properties</template>
|
||||
in the CSS styles, but unable to test if used or not.
|
||||
</span>
|
||||
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<span
|
||||
v-if="warning.Description !== ''"
|
||||
class="me-2"
|
||||
v-html="sanitizeHTML(warning.Description)"
|
||||
></span>
|
||||
<!-- -eslint-disable vue/no-v-html -->
|
||||
</p>
|
||||
|
||||
<template v-if="warning.Results.length">
|
||||
<h3 class="h6">Clients with partial or no support:</h3>
|
||||
<p>
|
||||
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4">
|
||||
<i class="bi bi-circle-fill"
|
||||
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i>
|
||||
{{ warning.Name }}
|
||||
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''"
|
||||
title="See notes">
|
||||
{{ warning.NoteNumber }}
|
||||
<small
|
||||
v-for="(warningRes, wi) in warning.Results"
|
||||
:key="'warning_results_' + wi"
|
||||
class="text-nowrap d-inline-block me-4"
|
||||
>
|
||||
<i
|
||||
class="bi bi-circle-fill"
|
||||
:class="warningRes.Support === 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="
|
||||
warningRes.Support === 'no' ? 'Not supported' : 'Partially supported'
|
||||
"
|
||||
></i>
|
||||
{{ warningRes.Name }}
|
||||
<span
|
||||
v-if="warningRes.NoteNumber !== ''"
|
||||
class="badge text-bg-secondary"
|
||||
title="See notes"
|
||||
>
|
||||
{{ warningRes.NoteNumber }}
|
||||
</span>
|
||||
</small>
|
||||
</p>
|
||||
@ -433,17 +517,21 @@ export default {
|
||||
|
||||
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
|
||||
<h3 class="h6">Notes:</h3>
|
||||
<div v-for="n, i in warning.NotesByNumber" class="small row my-2">
|
||||
<div
|
||||
v-for="(n, ni) in warning.NotesByNumber"
|
||||
:key="'warning_notes' + ni"
|
||||
class="small row my-2"
|
||||
>
|
||||
<div class="col-auto pe-0">
|
||||
<span class="badge text-bg-secondary">
|
||||
{{ i }}
|
||||
{{ ni }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col" v-html="n"></div>
|
||||
<div class="col" v-html="sanitizeHTML(n)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small mt-3 mb-0" v-if="warning.URL">
|
||||
<p v-if="warning.URL" class="small mt-3 mb-0">
|
||||
<a :href="warning.URL" target="_blank">Online reference</a>
|
||||
</p>
|
||||
</div>
|
||||
@ -452,30 +540,44 @@ export default {
|
||||
</div>
|
||||
|
||||
<p class="text-center text-muted small mt-4">
|
||||
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using
|
||||
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
||||
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using compatibility data
|
||||
from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutHTMLCheckResults"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1>
|
||||
<h1 id="AboutHTMLCheckResultsLabel" class="modal-title fs-5">About HTML check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="HTMLCheckAboutAccordion">
|
||||
<div id="HTMLCheckAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is HTML check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
The support for HTML/CSS messages varies greatly across email clients. HTML
|
||||
check attempts to calculate the overall support for your email for all selected
|
||||
@ -485,13 +587,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
How does it work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Internally the original HTML message is run against
|
||||
@ -504,10 +615,11 @@ export default {
|
||||
CSS support is very difficult to programmatically test, especially if a
|
||||
message contains CSS style blocks or is linked to remote stylesheets. Remote
|
||||
stylesheets are, unless blocked via
|
||||
<code>--block-remote-css-and-fonts</code>,
|
||||
downloaded and injected into the message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer"
|
||||
target="_blank">inlined</a>
|
||||
<code>--block-remote-css-and-fonts</code>, downloaded and injected into the
|
||||
message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer" target="_blank"
|
||||
>inlined</a
|
||||
>
|
||||
to matching HTML elements. This gives Mailpit fairly accurate results.
|
||||
</p>
|
||||
<p>
|
||||
@ -528,13 +640,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
Is the final score accurate?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
There are many ways to define "accurate", and how one should calculate the
|
||||
@ -578,13 +699,22 @@ export default {
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
What about invalid HTML?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
HTML check does not detect if the original HTML is valid. In order to detect
|
||||
applied styles to every node, the HTML email is run through a parser which is
|
||||
@ -592,7 +722,6 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -1,36 +0,0 @@
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
headers: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
|
||||
this.get(uri, false, (response) => {
|
||||
this.headers = response.data
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="headers" class="small">
|
||||
<div v-for="values, k in headers" class="row mb-2 pb-2 border-bottom w-100">
|
||||
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,16 +1,19 @@
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: Object,
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setLinkErrors"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
@ -19,116 +22,116 @@ export default {
|
||||
check: false,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.autoScan = localStorage.getItem('LinkCheckAutoScan')
|
||||
this.followRedirects = localStorage.getItem('LinkCheckFollowRedirects')
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loaded = true
|
||||
if (this.autoScan) {
|
||||
this.doCheck()
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
autoScan(v) {
|
||||
if (!this.loaded) {
|
||||
return
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem('LinkCheckAutoScan', true)
|
||||
if (!this.check) {
|
||||
this.doCheck()
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('LinkCheckAutoScan')
|
||||
}
|
||||
},
|
||||
followRedirects(v) {
|
||||
if (!this.loaded) {
|
||||
return
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem('LinkCheckFollowRedirects', true)
|
||||
} else {
|
||||
localStorage.removeItem('LinkCheckFollowRedirects')
|
||||
}
|
||||
if (this.check) {
|
||||
this.doCheck()
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
groupedStatuses() {
|
||||
let results = {}
|
||||
const results = {};
|
||||
|
||||
if (!this.check) {
|
||||
return results
|
||||
return results;
|
||||
}
|
||||
|
||||
// group by status
|
||||
this.check.Links.forEach(function (r) {
|
||||
this.check.Links.forEach((r) => {
|
||||
if (!results[r.StatusCode]) {
|
||||
let css = ""
|
||||
let css = "";
|
||||
if (r.StatusCode >= 400 || r.StatusCode === 0) {
|
||||
css = "text-danger"
|
||||
css = "text-danger";
|
||||
} else if (r.StatusCode >= 300) {
|
||||
css = "text-info"
|
||||
css = "text-info";
|
||||
}
|
||||
|
||||
if (r.StatusCode === 0) {
|
||||
r.Status = 'Cannot connect to server'
|
||||
r.Status = "Cannot connect to server";
|
||||
}
|
||||
results[r.StatusCode] = {
|
||||
StatusCode: r.StatusCode,
|
||||
Status: r.Status,
|
||||
Class: css,
|
||||
URLS: []
|
||||
URLS: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
results[r.StatusCode].URLS.push(r.URL)
|
||||
})
|
||||
results[r.StatusCode].URLS.push(r.URL);
|
||||
});
|
||||
|
||||
let newArr = []
|
||||
const newArr = [];
|
||||
|
||||
for (const i in results) {
|
||||
newArr.push(results[i])
|
||||
newArr.push(results[i]);
|
||||
}
|
||||
|
||||
// sort statuses
|
||||
let sorted = newArr.sort((a, b) => {
|
||||
const sorted = newArr.sort((a, b) => {
|
||||
if (a.StatusCode === 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
return a.StatusCode < b.StatusCode
|
||||
})
|
||||
return a.StatusCode < b.StatusCode;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
},
|
||||
},
|
||||
|
||||
return sorted
|
||||
watch: {
|
||||
autoScan(v) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem("LinkCheckAutoScan", true);
|
||||
if (!this.check) {
|
||||
this.doCheck();
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem("LinkCheckAutoScan");
|
||||
}
|
||||
},
|
||||
followRedirects(v) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem("LinkCheckFollowRedirects", true);
|
||||
} else {
|
||||
localStorage.removeItem("LinkCheckFollowRedirects");
|
||||
}
|
||||
if (this.check) {
|
||||
this.doCheck();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.autoScan = localStorage.getItem("LinkCheckAutoScan");
|
||||
this.followRedirects = localStorage.getItem("LinkCheckFollowRedirects");
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loaded = true;
|
||||
if (this.autoScan) {
|
||||
this.doCheck();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.loading = true
|
||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
|
||||
this.check = false;
|
||||
this.loading = true;
|
||||
let uri = this.resolve("/api/v1/message/" + this.message.ID + "/link-check");
|
||||
if (this.followRedirects) {
|
||||
uri += '?follow=true'
|
||||
uri += "?follow=true";
|
||||
}
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(uri, null)
|
||||
axios
|
||||
.get(uri, null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
|
||||
this.$emit('setLinkErrors', result.data.Errors)
|
||||
this.$emit("setLinkErrors", result.data.Errors);
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@ -136,27 +139,27 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
// always run
|
||||
this.loading = false
|
||||
})
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -164,24 +167,24 @@ export default {
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h4 class="mb-0">
|
||||
<template v-if="!check">
|
||||
Link check
|
||||
</template>
|
||||
<template v-if="!check"> Link check </template>
|
||||
<template v-else>
|
||||
<template v-if="check.Links.length">
|
||||
Scanned {{ formatNumber(check.Links.length) }}
|
||||
link<template v-if="check.Links.length != 1">s</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
No links detected
|
||||
Scanned {{ formatNumber(check.Links.length) }} link<template v-if="check.Links.length != 1"
|
||||
>s</template
|
||||
>
|
||||
</template>
|
||||
<template v-else> No links detected </template>
|
||||
</template>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutLinkCheckResults">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#AboutLinkCheckResults"
|
||||
>
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
@ -195,12 +198,12 @@ export default {
|
||||
|
||||
<div v-if="!check">
|
||||
<p class="text-muted">
|
||||
Link check scans your email text & HTML for unique links, testing the response status codes.
|
||||
This includes links to images and remote CSS stylesheets.
|
||||
Link check scans your email text & HTML for unique links, testing the response status codes. This
|
||||
includes links to images and remote CSS stylesheets.
|
||||
</p>
|
||||
|
||||
<p class="text-center my-5">
|
||||
<button v-if="!check" class="btn btn-primary btn-lg" @click="doCheck()" :disabled="loading">
|
||||
<button v-if="!check" class="btn btn-primary btn-lg" :disabled="loading" @click="doCheck()">
|
||||
<template v-if="loading">
|
||||
Checking links
|
||||
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
|
||||
@ -215,14 +218,14 @@ export default {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else v-for="s, k in groupedStatuses">
|
||||
<div v-for="(s, k) in groupedStatuses" v-else :key="k">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header h4" :class="s.Class">
|
||||
Status {{ s.StatusCode }}
|
||||
<small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li v-for="u in s.URLS" class="list-group-item">
|
||||
<li v-for="(u, i) in s.URLS" :key="'status' + i" class="list-group-item">
|
||||
<a :href="u" target="_blank" class="no-icon">{{ u }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
@ -235,22 +238,31 @@ export default {
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="LinkCheckOptions"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="LinkCheckOptionsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="LinkCheckOptionsLabel">Link check options</h1>
|
||||
<h1 id="LinkCheckOptionsLabel" class="modal-title fs-5">Link check options</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
|
||||
<div class="form-check form-switch mb-4">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects"
|
||||
id="LinkCheckFollowRedirectsSwitch">
|
||||
<input
|
||||
id="LinkCheckFollowRedirectsSwitch"
|
||||
v-model="followRedirects"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
|
||||
<template v-if="followRedirects">Following HTTP redirects</template>
|
||||
<template v-else>Not following HTTP redirects</template>
|
||||
@ -259,8 +271,13 @@ export default {
|
||||
|
||||
<h6 class="mt-4">Automatic link checking</h6>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="autoScan"
|
||||
id="LinkCheckAutoCheckSwitch">
|
||||
<input
|
||||
id="LinkCheckAutoCheckSwitch"
|
||||
v-model="autoScan"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="LinkCheckAutoCheckSwitch">
|
||||
<template v-if="autoScan">Automatic link checking is enabled</template>
|
||||
<template v-else>Automatic link checking is disabled</template>
|
||||
@ -270,7 +287,6 @@ export default {
|
||||
Only enable this if you understand the potential risks & consequences.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@ -279,25 +295,39 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="AboutLinkCheckResults" tabindex="-1" aria-labelledby="AboutLinkCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutLinkCheckResults"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutLinkCheckResultsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutLinkCheckResultsLabel">About Link check</h1>
|
||||
<h1 id="AboutLinkCheckResultsLabel" class="modal-title fs-5">About Link check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="LinkCheckAboutAccordion">
|
||||
<div id="LinkCheckAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is Link check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
Link check scans your message HTML and text for all unique links, images and linked
|
||||
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
|
||||
@ -307,35 +337,52 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
What are "301" and "302" links?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
These are links that redirect you to another URL, for example newsletters
|
||||
often use redirect links to track user clicks.
|
||||
These are links that redirect you to another URL, for example newsletters often
|
||||
use redirect links to track user clicks.
|
||||
</p>
|
||||
<p>
|
||||
By default Link check will not follow these links, however you can turn this on
|
||||
via
|
||||
the settings and Link check will "follow" those redirects.
|
||||
via the settings and Link check will "follow" those redirects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
Why are some links returning an error but work in my browser?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>This may be due to various reasons, for instance:</p>
|
||||
<ul>
|
||||
@ -352,13 +399,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
What are the risks of running Link check automatically?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Depending on the type of messages you are testing, opening all links on all
|
||||
@ -382,7 +438,6 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -1,657 +0,0 @@
|
||||
<script>
|
||||
import Attachments from './Attachments.vue'
|
||||
import Headers from './Headers.vue'
|
||||
import HTMLCheck from './HTMLCheck.vue'
|
||||
import LinkCheck from './LinkCheck.vue'
|
||||
import SpamAssassin from './SpamAssassin.vue'
|
||||
import Tags from 'bootstrap5-tags'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { mailbox } from '../../stores/mailbox'
|
||||
import DOMPurify from 'dompurify'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
|
||||
hljs.registerLanguage('html', xml)
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
Attachments,
|
||||
Headers,
|
||||
HTMLCheck,
|
||||
LinkCheck,
|
||||
SpamAssassin,
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
srcURI: false,
|
||||
iframes: [], // for resizing
|
||||
canSaveTags: false, // prevent auto-saving tags on render
|
||||
availableTags: [],
|
||||
messageTags: [],
|
||||
loadHeaders: false,
|
||||
htmlScore: false,
|
||||
htmlScoreColor: false,
|
||||
linkCheckErrors: false,
|
||||
spamScore: false,
|
||||
spamScoreColor: false,
|
||||
showMobileButtons: false,
|
||||
showUnsubscribe: false,
|
||||
scaleHTMLPreview: 'display',
|
||||
// keys names match bootstrap icon names
|
||||
responsiveSizes: {
|
||||
phone: 'width: 322px; height: 570px',
|
||||
tablet: 'width: 768px; height: 1024px',
|
||||
display: 'width: 100%; height: 100%',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
messageTags() {
|
||||
if (this.canSaveTags) {
|
||||
// save changes to tags
|
||||
this.saveTags()
|
||||
}
|
||||
},
|
||||
|
||||
scaleHTMLPreview(v) {
|
||||
if (v == 'display') {
|
||||
window.setTimeout(() => {
|
||||
this.resizeIFrames()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasAnyChecksEnabled() {
|
||||
return (mailbox.showHTMLCheck && this.message.HTML)
|
||||
|| mailbox.showLinkCheck
|
||||
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
||||
},
|
||||
|
||||
// remove bad HTML, JavaScript, iframes etc
|
||||
sanitizedHTML() {
|
||||
// set target & rel on all links
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName != 'A' || (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#')) {
|
||||
return
|
||||
}
|
||||
if ('target' in node) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
|
||||
node.setAttribute('xlink:show', '_blank');
|
||||
}
|
||||
});
|
||||
|
||||
const clean = DOMPurify.sanitize(
|
||||
this.message.HTML,
|
||||
{
|
||||
WHOLE_DOCUMENT: true,
|
||||
SANITIZE_DOM: false,
|
||||
ADD_TAGS: [
|
||||
'link',
|
||||
'meta',
|
||||
'o:p',
|
||||
'style',
|
||||
],
|
||||
ADD_ATTR: [
|
||||
'bordercolor',
|
||||
'charset',
|
||||
'content',
|
||||
'hspace',
|
||||
'http-equiv',
|
||||
'itemprop',
|
||||
'itemscope',
|
||||
'itemtype',
|
||||
'link',
|
||||
'vertical-align',
|
||||
'vlink',
|
||||
'vspace',
|
||||
'xml:lang',
|
||||
],
|
||||
FORBID_ATTR: ['script'],
|
||||
}
|
||||
)
|
||||
|
||||
// for debugging
|
||||
// this.debugDOMPurify(DOMPurify.removed)
|
||||
|
||||
return clean
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.canSaveTags = false
|
||||
this.messageTags = this.message.Tags
|
||||
this.renderUI()
|
||||
|
||||
window.addEventListener("resize", this.resizeIFrames)
|
||||
|
||||
let headersTab = document.getElementById('nav-headers-tab')
|
||||
headersTab.addEventListener('shown.bs.tab', (event) => {
|
||||
this.loadHeaders = true
|
||||
})
|
||||
|
||||
let rawTab = document.getElementById('nav-raw-tab')
|
||||
rawTab.addEventListener('shown.bs.tab', (event) => {
|
||||
this.srcURI = this.resolve('/api/v1/message/' + this.message.ID + '/raw')
|
||||
this.resizeIFrames()
|
||||
})
|
||||
|
||||
// manually refresh tags
|
||||
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
|
||||
this.availableTags = response.data
|
||||
this.$nextTick(() => {
|
||||
Tags.init('select[multiple]')
|
||||
// delay tag change detection to allow Tags to load
|
||||
window.setTimeout(() => {
|
||||
this.canSaveTags = true
|
||||
}, 200)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
isHTMLTabSelected() {
|
||||
this.showMobileButtons = this.$refs.navhtml
|
||||
&& this.$refs.navhtml.classList.contains('active')
|
||||
},
|
||||
|
||||
renderUI() {
|
||||
// activate the first non-disabled tab
|
||||
document.querySelector('#nav-tab button:not([disabled])').click()
|
||||
document.activeElement.blur() // blur focus
|
||||
document.getElementById('message-view').scrollTop = 0
|
||||
|
||||
this.isHTMLTabSelected()
|
||||
|
||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
|
||||
listObj.addEventListener('shown.bs.tab', (event) => {
|
||||
this.isHTMLTabSelected()
|
||||
})
|
||||
})
|
||||
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
|
||||
// delay 0.5s until vue has rendered the iframe content
|
||||
window.setTimeout(() => {
|
||||
let p = document.getElementById('preview-html')
|
||||
if (p && typeof p.contentWindow.document.body == 'object') {
|
||||
try {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
|
||||
if (href && href.match(/^https?:\/\//i)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) { }
|
||||
this.resizeIFrames()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// HTML highlighting
|
||||
hljs.highlightAll()
|
||||
},
|
||||
|
||||
resizeIframe(el) {
|
||||
let i = el.target
|
||||
if (typeof i.contentWindow.document.body.scrollHeight == 'number') {
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
},
|
||||
|
||||
resizeIFrames() {
|
||||
if (this.scaleHTMLPreview != 'display') {
|
||||
return
|
||||
}
|
||||
let h = document.getElementById('preview-html')
|
||||
if (h) {
|
||||
if (typeof h.contentWindow.document.body.scrollHeight == 'number') {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// set the iframe body & text colors based on current theme
|
||||
initRawIframe(el) {
|
||||
let bodyStyles = window.getComputedStyle(document.body, null)
|
||||
let bg = bodyStyles.getPropertyValue('background-color')
|
||||
let txt = bodyStyles.getPropertyValue('color')
|
||||
|
||||
let body = el.target.contentWindow.document.querySelector('body')
|
||||
if (body) {
|
||||
body.style.color = txt
|
||||
body.style.backgroundColor = bg
|
||||
}
|
||||
|
||||
this.resizeIframe(el)
|
||||
},
|
||||
|
||||
// this function is unused but kept here to use for debugging
|
||||
debugDOMPurify(removed) {
|
||||
if (!removed.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const ignoreNodes = ['target', 'base', 'script', 'v:shapes']
|
||||
|
||||
let d = removed.filter((r) => {
|
||||
if (typeof r.attribute != 'undefined' &&
|
||||
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith('xmlns:'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// inline comments
|
||||
if (typeof r.element != 'undefined' && (r.element.nodeType == 8 || r.element.tagName == 'SCRIPT')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (d.length) {
|
||||
console.log(d)
|
||||
}
|
||||
},
|
||||
|
||||
saveTags() {
|
||||
var data = {
|
||||
IDs: [this.message.ID],
|
||||
Tags: this.messageTags
|
||||
}
|
||||
|
||||
this.put(this.resolve('/api/v1/tags'), data, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.$emit('loadMessages')
|
||||
})
|
||||
},
|
||||
|
||||
// Convert plain text to HTML including anchor links
|
||||
textToHTML(s) {
|
||||
let html = s
|
||||
|
||||
// full links with http(s)
|
||||
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim
|
||||
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
|
||||
|
||||
// plain www links without https?:// prefix
|
||||
let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim
|
||||
html = html.replace(re2, '$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲')
|
||||
|
||||
// escape to HTML & convert <>" back
|
||||
html = html
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/˱˱˱/g, '<')
|
||||
.replace(/˲˲˲/g, '>')
|
||||
.replace(/ˠˠˠ/g, '"')
|
||||
|
||||
return html
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
||||
<div class="row w-100">
|
||||
<div class="col-md">
|
||||
<table class="messageHeaders">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small">From</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name" class="text-spaces">
|
||||
{{ message.From.Name + " " }}
|
||||
</span>
|
||||
<span v-if="message.From.Address" class="small">
|
||||
<<a :href="searchURI(message.From.Address)" class="text-body">
|
||||
{{ message.From.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
[ Unknown ]
|
||||
</span>
|
||||
|
||||
<span v-if="message.ListUnsubscribe.Header != ''" class="small ms-3 link"
|
||||
:title="showUnsubscribe ? 'Hide unsubscribe information' : 'Show unsubscribe information'"
|
||||
@click="showUnsubscribe = !showUnsubscribe">
|
||||
Unsubscribe
|
||||
<i class="bi bi bi-info-circle"
|
||||
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
|
||||
<template v-if="i > 0">, </template>
|
||||
<span>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||
<th>Cc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||
<th class="text-nowrap">Reply-To</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<span v-for="(t, i) in message.ReplyTo">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address"
|
||||
class="small">
|
||||
<th class="text-nowrap">Return-Path</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
||||
{{ message.ReturnPath }}
|
||||
</a>>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="small">Subject</th>
|
||||
<td>
|
||||
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
|
||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th class="small">Date</th>
|
||||
<td>
|
||||
{{ messageDate(message.Date) }}
|
||||
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Username" class="small">
|
||||
<th class="small">
|
||||
Username
|
||||
<i class="bi bi-exclamation-circle ms-1" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
||||
data-bs-title="The SMTP or send API username the client authenticated with">
|
||||
</i>
|
||||
</th>
|
||||
<td class="small">
|
||||
{{ message.Username }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
<select class="form-select small tag-selector" v-model="messageTags" multiple
|
||||
data-full-width="false" data-suggestions-threshold="1" data-allow-new="true"
|
||||
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
|
||||
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
|
||||
data-separator="|,|">
|
||||
<option value="">Type a tag...</option>
|
||||
<!-- you need at least one option with the placeholder -->
|
||||
<option v-for="t in availableTags" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="message.ListUnsubscribe.Header != ''" class="small"
|
||||
:class="showUnsubscribe ? '' : 'd-none'">
|
||||
<th>Unsubscribe</th>
|
||||
<td>
|
||||
<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2">
|
||||
<template v-for="(u, i) in message.ListUnsubscribe.Links">
|
||||
<template v-if="i > 0">, </template>
|
||||
<{{ u }}>
|
||||
</template>
|
||||
</span>
|
||||
<i class="bi bi-info-circle text-success me-2 link"
|
||||
v-if="message.ListUnsubscribe.HeaderPost != ''" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost">
|
||||
</i>
|
||||
<i class="bi bi-exclamation-circle text-danger link"
|
||||
v-if="message.ListUnsubscribe.Errors != ''" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="message.ListUnsubscribe.Errors">
|
||||
</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-auto d-none d-md-block text-end mt-md-3"
|
||||
v-if="message.Attachments && message.Attachments.length || message.Inline && message.Inline.length">
|
||||
<div class="mt-2 mt-md-0">
|
||||
<template v-if="message.Attachments.length">
|
||||
<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message">
|
||||
Attachment<span v-if="message.Attachments.length > 1">s</span>
|
||||
({{ message.Attachments.length }})
|
||||
</span>
|
||||
<br>
|
||||
</template>
|
||||
<span class="badge rounded-pill text-bg-secondary p-2" v-if="message.Inline.length"
|
||||
title="Inline images in this message">
|
||||
Inline image<span v-if="message.Inline.length > 1">s</span>
|
||||
({{ message.Inline.length }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav nav-tabs my-3 d-print-none" id="nav-tab" role="tablist">
|
||||
<template v-if="message.HTML">
|
||||
<div class="btn-group">
|
||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html"
|
||||
type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml"
|
||||
v-on:click="resizeIFrames()">
|
||||
HTML
|
||||
</button>
|
||||
<button type="button" class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
||||
data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" data-bs-toggle="tab" data-bs-target="#nav-html-source"
|
||||
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false">
|
||||
HTML Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="nav-link d-none d-sm-inline" id="nav-html-source-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
|
||||
aria-selected="false">
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
|
||||
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
Text
|
||||
</button>
|
||||
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
|
||||
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
|
||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||
</button>
|
||||
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<div class="dropdown d-xl-none" v-show="hasAnyChecksEnabled">
|
||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Checks
|
||||
</button>
|
||||
<ul class="dropdown-menu checks">
|
||||
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||
v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showLinkCheck">
|
||||
<button class="dropdown-item" id="nav-link-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false">
|
||||
Link Check
|
||||
<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
||||
<small>0</small>
|
||||
</span>
|
||||
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
||||
v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false" v-if="mailbox.showLinkCheck">
|
||||
Link Check
|
||||
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
|
||||
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||
<template v-for="_, key in responsiveSizes">
|
||||
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||
v-on:click="scaleHTMLPreview = key">
|
||||
<i class="bi" :class="'bi-' + key"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content mb-5" id="nav-tabContent">
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
|
||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0" v-if="message.HTML">
|
||||
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
|
||||
tabindex="0" :class="message.HTML == '' ? 'show' : ''">
|
||||
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
||||
<iframe v-if="srcURI" :src="srcURI" v-on:load="initRawIframe" frameborder="0"
|
||||
style="width: 100%; height: 300px"></iframe>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0">
|
||||
<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''" :message="message"
|
||||
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
|
||||
tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<SpamAssassin :message="message" @setSpamScore="(n) => spamScore = n"
|
||||
@set-badge-style="(v) => spamScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0" v-if="mailbox.showLinkCheck">
|
||||
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,84 +1,102 @@
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import ICAL from "ical.js"
|
||||
import dayjs from 'dayjs'
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import ICAL from "ical.js";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
attachments: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
attachments: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
ical: false
|
||||
}
|
||||
ical: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
openAttachment(part, e) {
|
||||
let filename = part.FileName
|
||||
let contentType = part.ContentType
|
||||
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)
|
||||
if (filename.match(/\.ics$/i) || contentType == 'text/calendar') {
|
||||
e.preventDefault()
|
||||
const filename = part.FileName;
|
||||
const contentType = part.ContentType;
|
||||
const href = this.resolve("/api/v1/message/" + this.message.ID + "/part/" + part.PartID);
|
||||
if (filename.match(/\.ics$/i) || contentType === "text/calendar") {
|
||||
e.preventDefault();
|
||||
|
||||
this.get(href, null, (response) => {
|
||||
let comp = new ICAL.Component(ICAL.parse(response.data))
|
||||
let vevent = comp.getFirstSubcomponent('vevent')
|
||||
const comp = new ICAL.Component(ICAL.parse(response.data));
|
||||
const vevent = comp.getFirstSubcomponent("vevent");
|
||||
if (!vevent) {
|
||||
alert('Error parsing ICS file')
|
||||
return
|
||||
alert("Error parsing ICS file");
|
||||
return;
|
||||
}
|
||||
let event = new ICAL.Event(vevent)
|
||||
const event = new ICAL.Event(vevent);
|
||||
|
||||
let summary = {}
|
||||
summary.link = href
|
||||
summary.status = vevent.getFirstPropertyValue('status')
|
||||
summary.url = vevent.getFirstPropertyValue('url')
|
||||
summary.summary = event.summary
|
||||
summary.description = event.description
|
||||
summary.location = event.location
|
||||
summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.end = dayjs(event.endDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.isRecurring = event.isRecurring()
|
||||
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false
|
||||
summary.attendees = []
|
||||
const summary = {};
|
||||
summary.link = href;
|
||||
summary.status = vevent.getFirstPropertyValue("status");
|
||||
summary.url = vevent.getFirstPropertyValue("url");
|
||||
summary.summary = event.summary;
|
||||
summary.description = event.description;
|
||||
summary.location = event.location;
|
||||
summary.start = dayjs(event.startDate).format("ddd, D MMM YYYY, h:mm a");
|
||||
summary.end = dayjs(event.endDate).format("ddd, D MMM YYYY, h:mm a");
|
||||
summary.isRecurring = event.isRecurring();
|
||||
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, "") : false;
|
||||
summary.attendees = [];
|
||||
event.attendees.forEach((a) => {
|
||||
if (a.jCal[1].cn) {
|
||||
summary.attendees.push(a.jCal[1].cn)
|
||||
summary.attendees.push(a.jCal[1].cn);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
|
||||
summary.timezone = vtimezone.getFirstPropertyValue("tzid")
|
||||
})
|
||||
summary.timezone = vtimezone.getFirstPropertyValue("tzid");
|
||||
});
|
||||
|
||||
this.ical = summary
|
||||
this.ical = summary;
|
||||
|
||||
// display modal
|
||||
this.modal('ICSView').show()
|
||||
})
|
||||
}
|
||||
this.modal("ICSView").show();
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-top pt-4">
|
||||
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px"
|
||||
@click="openAttachment(part, $event)">
|
||||
<img v-if="isImage(part)"
|
||||
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top"
|
||||
alt="">
|
||||
<img v-else
|
||||
<a
|
||||
v-for="part in attachments"
|
||||
:key="part.PartID"
|
||||
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||
class="card attachment float-start me-3 mb-3"
|
||||
target="_blank"
|
||||
style="width: 180px"
|
||||
@click="openAttachment(part, $event)"
|
||||
>
|
||||
<img
|
||||
v-if="isImage(part)"
|
||||
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
|
||||
class="card-img-top"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
|
||||
class="card-img-top" alt="">
|
||||
<div class="icon" v-if="!isImage(part)">
|
||||
class="card-img-top"
|
||||
alt=""
|
||||
/>
|
||||
<div v-if="!isImage(part)" class="icon">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="card-body border-0">
|
||||
@ -87,16 +105,16 @@ export default {
|
||||
<small>{{ getFileSize(part.Size) }}</small>
|
||||
</p>
|
||||
<p class="card-text mb-0 small">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer small border-0 text-center text-truncate">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="ICSView" tabindex="-1" aria-hidden="true">
|
||||
<div id="ICSView" class="modal fade" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@ -106,7 +124,7 @@ export default {
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="ical">
|
||||
<div v-if="ical" class="modal-body">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr v-if="ical.summary">
|
||||
@ -134,7 +152,9 @@ export default {
|
||||
</tr>
|
||||
<tr v-if="ical.url">
|
||||
<th>URL</th>
|
||||
<td><a :href="ical.url" target="_blank">{{ ical.url }}</a></td>
|
||||
<td>
|
||||
<a :href="ical.url" target="_blank">{{ ical.url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ical.organizer">
|
||||
<th>Organizer</th>
|
||||
@ -143,7 +163,7 @@ export default {
|
||||
<tr v-if="ical.attendees.length">
|
||||
<th>Attendees</th>
|
||||
<td>
|
||||
<span v-for="(a, i) in ical.attendees">
|
||||
<span v-for="(a, i) in ical.attendees" :key="'attendee_' + i">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ a }}
|
||||
</span>
|
||||
@ -154,12 +174,9 @@ export default {
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<a class="btn btn-primary" target="_blank" :href="ical.link">
|
||||
Download attachment
|
||||
</a>
|
||||
<a class="btn btn-primary" target="_blank" :href="ical.link"> Download attachment </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
40
server/ui-src/components/message/MessageHeaders.vue
Normal file
40
server/ui-src/components/message/MessageHeaders.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
headers: false,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const uri = this.resolve("/api/v1/message/" + this.message.ID + "/headers");
|
||||
this.get(uri, false, (response) => {
|
||||
this.headers = response.data;
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="headers" class="small">
|
||||
<div v-for="(values, k) in headers" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
|
||||
<div class="col-md-4 col-lg-3 col-xl-2 mb-2">
|
||||
<b>{{ k }}</b>
|
||||
</div>
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
861
server/ui-src/components/message/MessageItem.vue
Normal file
861
server/ui-src/components/message/MessageItem.vue
Normal file
@ -0,0 +1,861 @@
|
||||
<script>
|
||||
import Attachments from "./MessageAttachments.vue";
|
||||
import Headers from "./MessageHeaders.vue";
|
||||
import HTMLCheck from "./HTMLCheck.vue";
|
||||
import LinkCheck from "./LinkCheck.vue";
|
||||
import SpamAssassin from "./SpamAssassin.vue";
|
||||
import Tags from "bootstrap5-tags";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import { mailbox } from "../../stores/mailbox";
|
||||
import DOMPurify from "dompurify";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
|
||||
hljs.registerLanguage("html", xml);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Attachments,
|
||||
Headers,
|
||||
HTMLCheck,
|
||||
LinkCheck,
|
||||
SpamAssassin,
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
srcURI: false,
|
||||
iframes: [], // for resizing
|
||||
canSaveTags: false, // prevent auto-saving tags on render
|
||||
availableTags: [],
|
||||
messageTags: [],
|
||||
loadHeaders: false,
|
||||
htmlScore: false,
|
||||
htmlScoreColor: false,
|
||||
linkCheckErrors: false,
|
||||
spamScore: false,
|
||||
spamScoreColor: false,
|
||||
showMobileButtons: false,
|
||||
showUnsubscribe: false,
|
||||
scaleHTMLPreview: "display",
|
||||
// keys names match bootstrap icon names
|
||||
responsiveSizes: {
|
||||
phone: "width: 322px; height: 570px",
|
||||
tablet: "width: 768px; height: 1024px",
|
||||
display: "width: 100%; height: 100%",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasAnyChecksEnabled() {
|
||||
return (
|
||||
(mailbox.showHTMLCheck && this.message.HTML) ||
|
||||
mailbox.showLinkCheck ||
|
||||
(mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
||||
);
|
||||
},
|
||||
|
||||
// remove bad HTML, JavaScript, iframes etc
|
||||
sanitizedHTML() {
|
||||
// set target & rel on all links
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (
|
||||
node.tagName !== "A" ||
|
||||
(node.hasAttribute("href") && node.getAttribute("href").substring(0, 1) === "#")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if ("target" in node) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
if (!node.hasAttribute("target") && (node.hasAttribute("xlink:href") || node.hasAttribute("href"))) {
|
||||
node.setAttribute("xlink:show", "_blank");
|
||||
}
|
||||
});
|
||||
|
||||
const clean = DOMPurify.sanitize(this.message.HTML, {
|
||||
WHOLE_DOCUMENT: true,
|
||||
SANITIZE_DOM: false,
|
||||
ADD_TAGS: ["link", "meta", "o:p", "style"],
|
||||
ADD_ATTR: [
|
||||
"bordercolor",
|
||||
"charset",
|
||||
"content",
|
||||
"hspace",
|
||||
"http-equiv",
|
||||
"itemprop",
|
||||
"itemscope",
|
||||
"itemtype",
|
||||
"link",
|
||||
"vertical-align",
|
||||
"vlink",
|
||||
"vspace",
|
||||
"xml:lang",
|
||||
],
|
||||
FORBID_ATTR: ["script"],
|
||||
});
|
||||
|
||||
// for debugging
|
||||
// this.debugDOMPurify(DOMPurify.removed)
|
||||
|
||||
return clean;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
messageTags() {
|
||||
if (this.canSaveTags) {
|
||||
// save changes to tags
|
||||
this.saveTags();
|
||||
}
|
||||
},
|
||||
|
||||
scaleHTMLPreview(v) {
|
||||
if (v === "display") {
|
||||
window.setTimeout(() => {
|
||||
this.resizeIFrames();
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.canSaveTags = false;
|
||||
this.messageTags = this.message.Tags;
|
||||
this.renderUI();
|
||||
|
||||
window.addEventListener("resize", this.resizeIFrames);
|
||||
|
||||
const headersTab = document.getElementById("nav-headers-tab");
|
||||
headersTab.addEventListener("shown.bs.tab", (event) => {
|
||||
this.loadHeaders = true;
|
||||
});
|
||||
|
||||
const rawTab = document.getElementById("nav-raw-tab");
|
||||
rawTab.addEventListener("shown.bs.tab", (event) => {
|
||||
this.srcURI = this.resolve("/api/v1/message/" + this.message.ID + "/raw");
|
||||
this.resizeIFrames();
|
||||
});
|
||||
|
||||
// manually refresh tags
|
||||
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
|
||||
this.availableTags = response.data;
|
||||
this.$nextTick(() => {
|
||||
Tags.init("select[multiple]");
|
||||
// delay tag change detection to allow Tags to load
|
||||
window.setTimeout(() => {
|
||||
this.canSaveTags = true;
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
isHTMLTabSelected() {
|
||||
this.showMobileButtons = this.$refs.navhtml && this.$refs.navhtml.classList.contains("active");
|
||||
},
|
||||
|
||||
renderUI() {
|
||||
// activate the first non-disabled tab
|
||||
document.querySelector("#nav-tab button:not([disabled])").click();
|
||||
document.activeElement.blur(); // blur focus
|
||||
document.getElementById("message-view").scrollTop = 0;
|
||||
|
||||
this.isHTMLTabSelected();
|
||||
|
||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
|
||||
listObj.addEventListener("shown.bs.tab", (event) => {
|
||||
this.isHTMLTabSelected();
|
||||
});
|
||||
});
|
||||
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
|
||||
|
||||
// delay 0.5s until vue has rendered the iframe content
|
||||
window.setTimeout(() => {
|
||||
const p = document.getElementById("preview-html");
|
||||
if (p && typeof p.contentWindow.document.body === "object") {
|
||||
try {
|
||||
// make links open in new window
|
||||
const anchorEls = p.contentWindow.document.body.querySelectorAll("a");
|
||||
for (let i = 0; i < anchorEls.length; i++) {
|
||||
const anchorEl = anchorEls[i];
|
||||
const href = anchorEl.getAttribute("href");
|
||||
|
||||
if (href && href.match(/^https?:\/\//i)) {
|
||||
anchorEl.setAttribute("target", "_blank");
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
this.resizeIFrames();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// HTML highlighting
|
||||
hljs.highlightAll();
|
||||
},
|
||||
|
||||
resizeIframe(el) {
|
||||
const i = el.target;
|
||||
if (typeof i.contentWindow.document.body.scrollHeight === "number") {
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + "px";
|
||||
}
|
||||
},
|
||||
|
||||
resizeIFrames() {
|
||||
if (this.scaleHTMLPreview !== "display") {
|
||||
return;
|
||||
}
|
||||
const h = document.getElementById("preview-html");
|
||||
if (h) {
|
||||
if (typeof h.contentWindow.document.body.scrollHeight === "number") {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// set the iframe body & text colors based on current theme
|
||||
initRawIframe(el) {
|
||||
const bodyStyles = window.getComputedStyle(document.body, null);
|
||||
const bg = bodyStyles.getPropertyValue("background-color");
|
||||
const txt = bodyStyles.getPropertyValue("color");
|
||||
|
||||
const body = el.target.contentWindow.document.querySelector("body");
|
||||
if (body) {
|
||||
body.style.color = txt;
|
||||
body.style.backgroundColor = bg;
|
||||
}
|
||||
|
||||
this.resizeIframe(el);
|
||||
},
|
||||
|
||||
// this function is unused but kept here to use for debugging
|
||||
debugDOMPurify(removed) {
|
||||
if (!removed.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ignoreNodes = ["target", "base", "script", "v:shapes"];
|
||||
|
||||
const d = removed.filter((r) => {
|
||||
if (
|
||||
typeof r.attribute !== "undefined" &&
|
||||
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith("xmlns:"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// inline comments
|
||||
if (typeof r.element !== "undefined" && (r.element.nodeType === 8 || r.element.tagName === "SCRIPT")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (d.length) {
|
||||
console.log(d);
|
||||
}
|
||||
},
|
||||
|
||||
saveTags() {
|
||||
const data = {
|
||||
IDs: [this.message.ID],
|
||||
Tags: this.messageTags,
|
||||
};
|
||||
|
||||
this.put(this.resolve("/api/v1/tags"), data, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.$emit("loadMessages");
|
||||
});
|
||||
},
|
||||
|
||||
// Convert plain text to HTML including anchor links
|
||||
textToHTML(s) {
|
||||
let html = s;
|
||||
|
||||
// full links with http(s)
|
||||
const re = /(\b(https?|ftp):\/\/[-\w@:%_+'!.~#?,&//=;]+)/gim;
|
||||
html = html.replace(re, "˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲");
|
||||
|
||||
// plain www links without https?:// prefix
|
||||
const re2 = /(^|[^/])(www\.[\S]+(\b|$))/gim;
|
||||
html = html.replace(re2, "$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲");
|
||||
|
||||
// escape to HTML & convert <>" back
|
||||
html = html
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/˱˱˱/g, "<")
|
||||
.replace(/˲˲˲/g, ">")
|
||||
.replace(/ˠˠˠ/g, '"');
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
||||
<div class="row w-100">
|
||||
<div class="col-md">
|
||||
<table class="messageHeaders">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small">From</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name" class="text-spaces">
|
||||
{{ message.From.Name + " " }}
|
||||
</span>
|
||||
<span v-if="message.From.Address" class="small">
|
||||
<<a :href="searchURI(message.From.Address)" class="text-body">
|
||||
{{ message.From.Address }} </a
|
||||
>>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else> [ Unknown ] </span>
|
||||
|
||||
<span
|
||||
v-if="message.ListUnsubscribe.Header != ''"
|
||||
class="small ms-3 link"
|
||||
:title="
|
||||
showUnsubscribe
|
||||
? 'Hide unsubscribe information'
|
||||
: 'Show unsubscribe information'
|
||||
"
|
||||
@click="showUnsubscribe = !showUnsubscribe"
|
||||
>
|
||||
Unsubscribe
|
||||
<i
|
||||
class="bi bi bi-info-circle"
|
||||
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"
|
||||
></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td class="privacy">
|
||||
<template v-if="message.To && message.To.length">
|
||||
<span v-for="(t, i) in message.To" :key="'to_' + i">
|
||||
<template v-if="i > 0">, </template>
|
||||
<span>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a
|
||||
>>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||
<th>Cc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc" :key="'cc_' + i">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc" :key="'bcc_' + i">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||
<th class="text-nowrap">Reply-To</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<span v-for="(t, i) in message.ReplyTo" :key="'bcc_' + i">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body-secondary"> {{ t.Address }} </a
|
||||
>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address"
|
||||
class="small"
|
||||
>
|
||||
<th class="text-nowrap">Return-Path</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
||||
{{ message.ReturnPath }} </a
|
||||
>>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="small">Subject</th>
|
||||
<td>
|
||||
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
|
||||
<small v-else class="text-body-secondary">[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th class="small">Date</th>
|
||||
<td>
|
||||
{{ messageDate(message.Date) }}
|
||||
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Username" class="small">
|
||||
<th class="small">
|
||||
Username
|
||||
<i
|
||||
class="bi bi-exclamation-circle ms-1"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
data-bs-custom-class="custom-tooltip"
|
||||
data-bs-title="The SMTP or send API username the client authenticated with"
|
||||
>
|
||||
</i>
|
||||
</th>
|
||||
<td class="small">
|
||||
{{ message.Username }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
<select
|
||||
v-model="messageTags"
|
||||
class="form-select small tag-selector"
|
||||
multiple
|
||||
data-full-width="false"
|
||||
data-suggestions-threshold="1"
|
||||
data-allow-new="true"
|
||||
data-clear-end="true"
|
||||
data-allow-clear="true"
|
||||
data-placeholder="Add tags..."
|
||||
data-badge-style="secondary"
|
||||
data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
|
||||
data-separator="|,|"
|
||||
>
|
||||
<option value="">Type a tag...</option>
|
||||
<!-- you need at least one option with the placeholder -->
|
||||
<option v-for="t in availableTags" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-if="message.ListUnsubscribe.Header != ''"
|
||||
class="small"
|
||||
:class="showUnsubscribe ? '' : 'd-none'"
|
||||
>
|
||||
<th>Unsubscribe</th>
|
||||
<td>
|
||||
<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2">
|
||||
<template v-for="(u, i) in message.ListUnsubscribe.Links">
|
||||
<template v-if="i > 0">, </template>
|
||||
<{{ u }}>
|
||||
</template>
|
||||
</span>
|
||||
<i
|
||||
v-if="message.ListUnsubscribe.HeaderPost != ''"
|
||||
class="bi bi-info-circle text-success me-2 link"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost"
|
||||
>
|
||||
</i>
|
||||
<i
|
||||
v-if="message.ListUnsubscribe.Errors != ''"
|
||||
class="bi bi-exclamation-circle text-danger link"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="message.ListUnsubscribe.Errors"
|
||||
>
|
||||
</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="(message.Attachments && message.Attachments.length) || (message.Inline && message.Inline.length)"
|
||||
class="col-md-auto d-none d-md-block text-end mt-md-3"
|
||||
>
|
||||
<div class="mt-2 mt-md-0">
|
||||
<template v-if="message.Attachments.length">
|
||||
<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message">
|
||||
Attachment<span v-if="message.Attachments.length > 1">s</span> ({{
|
||||
message.Attachments.length
|
||||
}})
|
||||
</span>
|
||||
<br />
|
||||
</template>
|
||||
<span
|
||||
v-if="message.Inline.length"
|
||||
class="badge rounded-pill text-bg-secondary p-2"
|
||||
title="Inline images in this message"
|
||||
>
|
||||
Inline image<span v-if="message.Inline.length > 1">s</span> ({{ message.Inline.length }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="nav-tab" class="nav nav-tabs my-3 d-print-none" role="tablist">
|
||||
<template v-if="message.HTML">
|
||||
<div class="btn-group">
|
||||
<button
|
||||
id="nav-html-tab"
|
||||
ref="navhtml"
|
||||
class="nav-link"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-html"
|
||||
aria-selected="true"
|
||||
@click="resizeIFrames()"
|
||||
>
|
||||
HTML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
data-bs-reference="parent"
|
||||
>
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-html-source"
|
||||
aria-selected="false"
|
||||
>
|
||||
HTML Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="nav-html-source-tab"
|
||||
class="nav-link d-none d-sm-inline"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-html-source"
|
||||
aria-selected="false"
|
||||
>
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
id="nav-plain-text-tab"
|
||||
class="nav-link"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-plain-text"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-plain-text"
|
||||
aria-selected="false"
|
||||
:class="message.HTML == '' ? 'show' : ''"
|
||||
>
|
||||
Text
|
||||
</button>
|
||||
<button
|
||||
id="nav-headers-tab"
|
||||
class="nav-link"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-headers"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-headers"
|
||||
aria-selected="false"
|
||||
>
|
||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||
</button>
|
||||
<button
|
||||
id="nav-raw-tab"
|
||||
class="nav-link"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-raw"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-raw"
|
||||
aria-selected="false"
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
<div v-show="hasAnyChecksEnabled" class="dropdown d-xl-none">
|
||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Checks
|
||||
</button>
|
||||
<ul class="dropdown-menu checks">
|
||||
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
<button
|
||||
id="nav-html-check-tab"
|
||||
class="dropdown-item"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-check"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-html"
|
||||
aria-selected="false"
|
||||
>
|
||||
HTML Check
|
||||
<span
|
||||
v-if="htmlScore !== false"
|
||||
class="badge rounded-pill p-1 float-end"
|
||||
:class="htmlScoreColor"
|
||||
>
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showLinkCheck">
|
||||
<button
|
||||
id="nav-link-check-tab"
|
||||
class="dropdown-item"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-link-check"
|
||||
aria-selected="false"
|
||||
>
|
||||
Link Check
|
||||
<span v-if="linkCheckErrors === 0" class="badge rounded-pill bg-success float-end">
|
||||
<small>0</small>
|
||||
</span>
|
||||
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger float-end">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<button
|
||||
id="nav-spam-check-tab"
|
||||
class="dropdown-item"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-spam-check"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-html"
|
||||
aria-selected="false"
|
||||
>
|
||||
Spam Analysis
|
||||
<span
|
||||
v-if="spamScore !== false"
|
||||
class="badge rounded-pill float-end"
|
||||
:class="spamScoreColor"
|
||||
>
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
v-if="mailbox.showHTMLCheck && message.HTML != ''"
|
||||
id="nav-html-check-tab"
|
||||
class="d-none d-xl-inline-block nav-link position-relative"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-check"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-html"
|
||||
aria-selected="false"
|
||||
>
|
||||
HTML Check
|
||||
<span v-if="htmlScore !== false" class="badge rounded-pill p-1" :class="htmlScoreColor">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mailbox.showLinkCheck"
|
||||
id="nav-link-check-tab"
|
||||
class="d-none d-xl-inline-block nav-link"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-link-check"
|
||||
aria-selected="false"
|
||||
>
|
||||
Link Check
|
||||
<i v-if="linkCheckErrors === 0" class="bi bi-check-all text-success"></i>
|
||||
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
|
||||
id="nav-spam-check-tab"
|
||||
class="d-none d-xl-inline-block nav-link position-relative"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#nav-spam-check"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="nav-html"
|
||||
aria-selected="false"
|
||||
>
|
||||
Spam Analysis
|
||||
<span v-if="spamScore !== false" class="badge rounded-pill" :class="spamScoreColor">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showMobileButtons" class="d-none d-lg-block ms-auto me-3">
|
||||
<template v-for="(_, key) in responsiveSizes" :key="'responsive_' + key">
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="scaleHTMLPreview == key"
|
||||
:title="'Switch to ' + key + ' view'"
|
||||
@click="scaleHTMLPreview = key"
|
||||
>
|
||||
<i class="bi" :class="'bi-' + key"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="nav-tabContent" class="tab-content mb-5">
|
||||
<div
|
||||
v-if="message.HTML != ''"
|
||||
id="nav-html"
|
||||
class="tab-pane fade show"
|
||||
role="tabpanel"
|
||||
aria-labelledby="nav-html-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe
|
||||
id="preview-html"
|
||||
target-blank=""
|
||||
class="tab-pane d-block"
|
||||
:srcdoc="sanitizedHTML"
|
||||
frameborder="0"
|
||||
style="width: 100%; height: 100%; background: #fff"
|
||||
@load="resizeIframe"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments
|
||||
v-if="allAttachments(message).length"
|
||||
:message="message"
|
||||
:attachments="allAttachments(message)"
|
||||
>
|
||||
</Attachments>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.HTML"
|
||||
id="nav-html-source"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
</div>
|
||||
<div
|
||||
id="nav-plain-text"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="nav-plain-text-tab"
|
||||
tabindex="0"
|
||||
:class="message.HTML == '' ? 'show' : ''"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
||||
<!-- -eslint-disable vue/no-v-html -->
|
||||
<Attachments
|
||||
v-if="allAttachments(message).length"
|
||||
:message="message"
|
||||
:attachments="allAttachments(message)"
|
||||
>
|
||||
</Attachments>
|
||||
</div>
|
||||
<div id="nav-headers" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||
</div>
|
||||
<div id="nav-raw" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
||||
<iframe
|
||||
v-if="srcURI"
|
||||
:src="srcURI"
|
||||
frameborder="0"
|
||||
style="width: 100%; height: 300px"
|
||||
@load="initRawIframe"
|
||||
></iframe>
|
||||
</div>
|
||||
<div
|
||||
id="nav-html-check"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<HTMLCheck
|
||||
v-if="mailbox.showHTMLCheck && message.HTML != ''"
|
||||
:message="message"
|
||||
@set-html-score="(n) => (htmlScore = n)"
|
||||
@set-badge-style="(v) => (htmlScoreColor = v)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
|
||||
id="nav-spam-check"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="nav-spam-check-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<SpamAssassin
|
||||
:message="message"
|
||||
@set-spam-score="(n) => (spamScore = n)"
|
||||
@set-badge-style="(v) => (spamScoreColor = v)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="mailbox.showLinkCheck"
|
||||
id="nav-link-check"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<LinkCheck :message="message" @set-link-errors="(n) => (linkCheckErrors = n)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,19 +1,24 @@
|
||||
<script>
|
||||
import AjaxLoader from '../AjaxLoader.vue'
|
||||
import Tags from "bootstrap5-tags"
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { mailbox } from '../../stores/mailbox'
|
||||
import AjaxLoader from "../AjaxLoader.vue";
|
||||
import Tags from "bootstrap5-tags";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import { mailbox } from "../../stores/mailbox";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
emits: ['delete'],
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["delete"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
@ -21,64 +26,62 @@ export default {
|
||||
deleteAfterRelease: false,
|
||||
mailbox,
|
||||
allAddresses: [],
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
mounted() {
|
||||
let a = []
|
||||
for (let i in this.message.To) {
|
||||
a.push(this.message.To[i].Address)
|
||||
const a = [];
|
||||
for (const i in this.message.To) {
|
||||
a.push(this.message.To[i].Address);
|
||||
}
|
||||
for (let i in this.message.Cc) {
|
||||
a.push(this.message.Cc[i].Address)
|
||||
for (const i in this.message.Cc) {
|
||||
a.push(this.message.Cc[i].Address);
|
||||
}
|
||||
for (let i in this.message.Bcc) {
|
||||
a.push(this.message.Bcc[i].Address)
|
||||
for (const i in this.message.Bcc) {
|
||||
a.push(this.message.Bcc[i].Address);
|
||||
}
|
||||
|
||||
// include only unique email addresses, regardless of casing
|
||||
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map(ad => [ad.toLowerCase(), ad])).values()]))
|
||||
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map((ad) => [ad.toLowerCase(), ad])).values()]));
|
||||
|
||||
this.addresses = this.allAddresses
|
||||
this.addresses = this.allAddresses;
|
||||
},
|
||||
|
||||
methods: {
|
||||
// triggered manually after modal is shown
|
||||
initTags() {
|
||||
Tags.init("select[multiple]")
|
||||
Tags.init("select[multiple]");
|
||||
},
|
||||
|
||||
releaseMessage() {
|
||||
// set timeout to allow for user clicking send before the tag filter has applied the tag
|
||||
window.setTimeout(() => {
|
||||
if (!this.addresses.length) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let data = {
|
||||
To: this.addresses
|
||||
}
|
||||
const data = {
|
||||
To: this.addresses,
|
||||
};
|
||||
|
||||
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => {
|
||||
this.modal("ReleaseModal").hide()
|
||||
this.post(this.resolve("/api/v1/message/" + this.message.ID + "/release"), data, (response) => {
|
||||
this.modal("ReleaseModal").hide();
|
||||
if (this.deleteAfterRelease) {
|
||||
this.$emit('delete')
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
this.$emit("delete");
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" v-if="message">
|
||||
<div id="ReleaseModal" class="modal fade" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||
<div v-if="message" class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
|
||||
<h1 id="AppInfoModalLabel" class="modal-title fs-5">Release email</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@ -86,32 +89,55 @@ export default {
|
||||
<div class="row">
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
|
||||
<div class="col-sm-10">
|
||||
<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text"
|
||||
aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="mailbox.uiConfig.MessageRelay.OverrideFrom">
|
||||
<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="message.From ? message.From.Address : ''">
|
||||
<input
|
||||
v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''"
|
||||
type="text"
|
||||
aria-label="From address"
|
||||
readonly
|
||||
class="form-control-plaintext"
|
||||
:value="mailbox.uiConfig.MessageRelay.OverrideFrom"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
aria-label="From address"
|
||||
readonly
|
||||
class="form-control-plaintext"
|
||||
:value="message.From ? message.From.Address : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">Subject</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" aria-label="Subject" readonly class="form-control-plaintext"
|
||||
:value="message.Subject">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Subject"
|
||||
readonly
|
||||
class="form-control-plaintext"
|
||||
:value="message.Subject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
|
||||
data-clear-end="true" data-allow-clear="true"
|
||||
data-placeholder="Enter email addresses..." data-add-on-blur="true"
|
||||
<select
|
||||
v-model="addresses"
|
||||
class="form-select tag-selector"
|
||||
multiple
|
||||
data-allow-new="true"
|
||||
data-clear-end="true"
|
||||
data-allow-clear="true"
|
||||
data-placeholder="Enter email addresses..."
|
||||
data-add-on-blur="true"
|
||||
data-badge-style="primary"
|
||||
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
|
||||
data-separator="|,|">
|
||||
data-separator="|,|"
|
||||
>
|
||||
<option value="">Enter email addresses...</option>
|
||||
<!-- you need at least one option with the placeholder -->
|
||||
<option v-for="t in allAddresses" :value="t">{{ t }}</option>
|
||||
<option v-for="t in allAddresses" :key="'address+' + t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Invalid email address</div>
|
||||
</div>
|
||||
@ -119,8 +145,12 @@ export default {
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-10 offset-sm-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" v-model="deleteAfterRelease"
|
||||
id="DeleteAfterRelease">
|
||||
<input
|
||||
id="DeleteAfterRelease"
|
||||
v-model="deleteAfterRelease"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="DeleteAfterRelease">
|
||||
Delete the message after release
|
||||
</label>
|
||||
@ -145,7 +175,8 @@ export default {
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
|
||||
The <code>From</code> email address has been overridden by the relay configuration to
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code>.
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code
|
||||
>.
|
||||
</li>
|
||||
<li class="form-text">
|
||||
SMTP delivery failures will bounce back to
|
||||
@ -155,14 +186,16 @@ export default {
|
||||
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
|
||||
</code>
|
||||
<code v-else>{{ message.ReturnPath }}</code>.
|
||||
<code v-else>{{ message.ReturnPath }}</code
|
||||
>.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
|
||||
v-on:click="releaseMessage">Release</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!addresses.length" @click="releaseMessage">
|
||||
Release
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
165
server/ui-src/components/message/MessageScreenshot.vue
Normal file
165
server/ui-src/components/message/MessageScreenshot.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<script>
|
||||
import AjaxLoader from "../AjaxLoader.vue";
|
||||
import CommonMixins from "../../mixins/CommonMixins";
|
||||
import { domToPng } from "modern-screenshot";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
html: false,
|
||||
loading: 0,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
initScreenshot() {
|
||||
this.loading = 1;
|
||||
// remove base tag, if set
|
||||
let h = this.message.HTML.replace(/<base .*>/im, "");
|
||||
const proxy = this.resolve("/proxy");
|
||||
|
||||
// Outlook hacks - else screenshot returns blank image
|
||||
h = h.replace(/<html [^>]+>/gim, "<html>"); // remove html attributes
|
||||
h = h.replace(/<o:p><\/o:p>/gm, ""); // remove empty `<o:p></o:p>` tags
|
||||
h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>`
|
||||
h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>`
|
||||
|
||||
// update any inline `url(...)` absolute links
|
||||
const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim;
|
||||
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
|
||||
if (typeof p2 === "string") {
|
||||
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`;
|
||||
}
|
||||
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`;
|
||||
});
|
||||
|
||||
// create temporary document to manipulate
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.open();
|
||||
doc.write(h);
|
||||
doc.close();
|
||||
|
||||
// remove any <script> tags
|
||||
const scripts = doc.getElementsByTagName("script");
|
||||
for (const i of scripts) {
|
||||
i.parentNode.removeChild(i);
|
||||
}
|
||||
|
||||
// replace stylesheet links with proxy links
|
||||
const stylesheets = doc.getElementsByTagName("link");
|
||||
for (const i of stylesheets) {
|
||||
const src = i.getAttribute("href");
|
||||
|
||||
if (
|
||||
src &&
|
||||
src.match(/^https?:\/\//i) &&
|
||||
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||
) {
|
||||
i.setAttribute("href", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||
}
|
||||
}
|
||||
|
||||
// replace images with proxy links
|
||||
const images = doc.getElementsByTagName("img");
|
||||
for (const i of images) {
|
||||
const src = i.getAttribute("src");
|
||||
if (
|
||||
src &&
|
||||
src.match(/^https?:\/\//i) &&
|
||||
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||
) {
|
||||
i.setAttribute("src", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||
}
|
||||
}
|
||||
|
||||
// replace background="" attributes with proxy links
|
||||
const backgrounds = doc.querySelectorAll("[background]");
|
||||
for (const i of backgrounds) {
|
||||
const src = i.getAttribute("background");
|
||||
|
||||
if (
|
||||
src &&
|
||||
src.match(/^https?:\/\//i) &&
|
||||
src.indexOf(window.location.origin + window.location.pathname) !== 0
|
||||
) {
|
||||
// replace with proxy link
|
||||
i.setAttribute("background", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
|
||||
}
|
||||
}
|
||||
|
||||
// set html with manipulated document content
|
||||
this.html = new XMLSerializer().serializeToString(doc);
|
||||
},
|
||||
|
||||
// HTML decode function
|
||||
decodeEntities(s) {
|
||||
const e = document.createElement("div");
|
||||
e.innerHTML = s;
|
||||
const str = e.textContent;
|
||||
e.textContent = "";
|
||||
return str;
|
||||
},
|
||||
|
||||
doScreenshot() {
|
||||
let width = document.getElementById("message-view").getBoundingClientRect().width;
|
||||
|
||||
const prev = document.getElementById("preview-html");
|
||||
if (prev && prev.getBoundingClientRect().width) {
|
||||
width = prev.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
if (width < 300) {
|
||||
width = 300;
|
||||
}
|
||||
|
||||
const i = document.getElementById("screenshot-html");
|
||||
|
||||
// set the iframe width
|
||||
i.style.width = width + "px";
|
||||
|
||||
const body = i.contentWindow.document.querySelector("body");
|
||||
|
||||
// take screenshot of iframe
|
||||
domToPng(body, {
|
||||
backgroundColor: "#ffffff",
|
||||
height: i.contentWindow.document.body.scrollHeight + 20,
|
||||
width,
|
||||
}).then((dataUrl) => {
|
||||
const link = document.createElement("a");
|
||||
link.download = this.message.ID + ".png";
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
this.loading = 0;
|
||||
this.html = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
v-if="html"
|
||||
id="screenshot-html"
|
||||
:srcdoc="html"
|
||||
frameborder="0"
|
||||
style="position: absolute; margin-left: -100000px"
|
||||
@load="doScreenshot"
|
||||
>
|
||||
</iframe>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
@ -1,144 +0,0 @@
|
||||
<script>
|
||||
import AjaxLoader from '../AjaxLoader.vue'
|
||||
import CommonMixins from '../../mixins/CommonMixins'
|
||||
import { domToPng } from 'modern-screenshot'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
html: false,
|
||||
loading: 0
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
initScreenshot() {
|
||||
this.loading = 1
|
||||
// remove base tag, if set
|
||||
let h = this.message.HTML.replace(/<base .*>/mi, '')
|
||||
let proxy = this.resolve('/proxy')
|
||||
|
||||
// Outlook hacks - else screenshot returns blank image
|
||||
h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes
|
||||
h = h.replace(/<o:p><\/o:p>/mg, '') // remove empty `<o:p></o:p>` tags
|
||||
h = h.replace(/<o:/mg, '<') // replace `<o:p>` tags with `<p>`
|
||||
h = h.replace(/<\/o:/mg, '</') // replace `</o:p>` tags with `</p>`
|
||||
|
||||
// update any inline `url(...)` absolute links
|
||||
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
|
||||
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
|
||||
if (typeof p2 === 'string') {
|
||||
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
|
||||
}
|
||||
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
|
||||
})
|
||||
|
||||
// create temporary document to manipulate
|
||||
let doc = document.implementation.createHTMLDocument();
|
||||
doc.open()
|
||||
doc.write(h)
|
||||
doc.close()
|
||||
|
||||
// remove any <script> tags
|
||||
let scripts = doc.getElementsByTagName('script')
|
||||
for (let i of scripts) {
|
||||
i.parentNode.removeChild(i)
|
||||
}
|
||||
|
||||
// replace stylesheet links with proxy links
|
||||
let stylesheets = doc.getElementsByTagName('link')
|
||||
for (let i of stylesheets) {
|
||||
let src = i.getAttribute('href')
|
||||
|
||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
||||
}
|
||||
}
|
||||
|
||||
// replace images with proxy links
|
||||
let images = doc.getElementsByTagName('img')
|
||||
for (let i of images) {
|
||||
let src = i.getAttribute('src')
|
||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
||||
}
|
||||
}
|
||||
|
||||
// replace background="" attributes with proxy links
|
||||
let backgrounds = doc.querySelectorAll("[background]")
|
||||
for (let i of backgrounds) {
|
||||
let src = i.getAttribute('background')
|
||||
|
||||
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||
// replace with proxy link
|
||||
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
|
||||
}
|
||||
}
|
||||
|
||||
// set html with manipulated document content
|
||||
this.html = new XMLSerializer().serializeToString(doc)
|
||||
},
|
||||
|
||||
// HTML decode function
|
||||
decodeEntities(s) {
|
||||
let e = document.createElement('div')
|
||||
e.innerHTML = s
|
||||
let str = e.textContent
|
||||
e.textContent = ''
|
||||
return str
|
||||
},
|
||||
|
||||
doScreenshot() {
|
||||
let width = document.getElementById('message-view').getBoundingClientRect().width
|
||||
|
||||
let prev = document.getElementById('preview-html')
|
||||
if (prev && prev.getBoundingClientRect().width) {
|
||||
width = prev.getBoundingClientRect().width
|
||||
}
|
||||
|
||||
if (width < 300) {
|
||||
width = 300
|
||||
}
|
||||
|
||||
const i = document.getElementById('screenshot-html')
|
||||
|
||||
// set the iframe width
|
||||
i.style.width = width + 'px'
|
||||
|
||||
let body = i.contentWindow.document.querySelector('body')
|
||||
|
||||
// take screenshot of iframe
|
||||
domToPng(body, {
|
||||
backgroundColor: '#ffffff',
|
||||
height: i.contentWindow.document.body.scrollHeight + 20,
|
||||
width: width,
|
||||
}).then(dataUrl => {
|
||||
const link = document.createElement('a')
|
||||
link.download = this.message.ID + '.png'
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
this.loading = 0
|
||||
this.html = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe v-if="html" :srcdoc="html" v-on:load="doScreenshot" frameborder="0" id="screenshot-html"
|
||||
style="position: absolute; margin-left: -100000px;">
|
||||
</iframe>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
@ -1,52 +1,85 @@
|
||||
<script>
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { VcDonut } from "vue-css-donut-chart";
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setSpamScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setSpamScore", "setBadgeStyle"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
check: false,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.doCheck()
|
||||
computed: {
|
||||
graphSections() {
|
||||
const score = this.check.Score;
|
||||
let p = Math.round((score / 5) * 100);
|
||||
if (p > 100) {
|
||||
p = 100;
|
||||
} else if (p < 0) {
|
||||
p = 0;
|
||||
}
|
||||
|
||||
let c = "#ffc107";
|
||||
if (this.check.IsSpam) {
|
||||
c = "#dc3545";
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: score + " / 5",
|
||||
value: p,
|
||||
color: c,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
scoreColor() {
|
||||
return this.graphSections[0].color;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setSpamScore', false)
|
||||
this.doCheck()
|
||||
this.$emit("setSpamScore", false);
|
||||
this.doCheck();
|
||||
},
|
||||
deep: true
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.doCheck();
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.check = false;
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null)
|
||||
axios
|
||||
.get(this.resolve("/api/v1/message/" + this.message.ID + "/sa-check"), null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.setIcons()
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
this.setIcons();
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@ -54,80 +87,50 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
badgeStyle(ignorePadding = false) {
|
||||
let badgeStyle = 'bg-success'
|
||||
let badgeStyle = "bg-success";
|
||||
if (this.check.Error) {
|
||||
badgeStyle = 'bg-warning text-primary'
|
||||
}
|
||||
else if (this.check.IsSpam) {
|
||||
badgeStyle = 'bg-danger'
|
||||
badgeStyle = "bg-warning text-primary";
|
||||
} else if (this.check.IsSpam) {
|
||||
badgeStyle = "bg-danger";
|
||||
} else if (this.check.Score >= 4) {
|
||||
badgeStyle = 'bg-warning text-primary'
|
||||
badgeStyle = "bg-warning text-primary";
|
||||
}
|
||||
|
||||
if (!ignorePadding && String(this.check.Score).includes('.')) {
|
||||
badgeStyle += " p-1"
|
||||
if (!ignorePadding && String(this.check.Score).includes(".")) {
|
||||
badgeStyle += " p-1";
|
||||
}
|
||||
|
||||
return badgeStyle
|
||||
return badgeStyle;
|
||||
},
|
||||
|
||||
setIcons() {
|
||||
let score = this.check.Score
|
||||
if (this.check.Error && this.check.Error != '') {
|
||||
score = '!'
|
||||
let score = this.check.Score;
|
||||
if (this.check.Error && this.check.Error !== "") {
|
||||
score = "!";
|
||||
}
|
||||
let badgeStyle = this.badgeStyle()
|
||||
this.$emit('setBadgeStyle', badgeStyle)
|
||||
this.$emit('setSpamScore', score)
|
||||
const badgeStyle = this.badgeStyle();
|
||||
this.$emit("setBadgeStyle", badgeStyle);
|
||||
this.$emit("setSpamScore", score);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
graphSections() {
|
||||
let score = this.check.Score
|
||||
let p = Math.round(score / 5 * 100)
|
||||
if (p > 100) {
|
||||
p = 100
|
||||
} else if (p < 0) {
|
||||
p = 0
|
||||
}
|
||||
|
||||
let c = '#ffc107'
|
||||
if (this.check.IsSpam) {
|
||||
c = '#dc3545'
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: score + ' / 5',
|
||||
value: p,
|
||||
color: c
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
scoreColor() {
|
||||
return this.graphSections[0].color
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -145,10 +148,10 @@ export default {
|
||||
|
||||
<template v-if="error || check.Error != ''">
|
||||
<p>Your message could not be checked</p>
|
||||
<div class="alert alert-warning" v-if="error">
|
||||
<div v-if="error" class="alert alert-warning">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div class="alert alert-warning" v-else>
|
||||
<div v-else class="alert alert-warning">
|
||||
There was an error contacting the configured SpamAssassin server: {{ check.Error }}
|
||||
</div>
|
||||
</template>
|
||||
@ -156,11 +159,18 @@ export default {
|
||||
<template v-else-if="check">
|
||||
<div class="row w-100 mt-5">
|
||||
<div class="col-xl-5 mb-2">
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ check.Score }} / 5
|
||||
</h2>
|
||||
<vc-donut
|
||||
:sections="graphSections"
|
||||
background="var(--bs-body-bg)"
|
||||
:size="230"
|
||||
unit="px"
|
||||
:thickness="20"
|
||||
:total="100"
|
||||
:start-angle="270"
|
||||
:auto-adjust-text-size="true"
|
||||
foreground="#198754"
|
||||
>
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">{{ check.Score }} / 5</h2>
|
||||
<div class="text-body mt-2">
|
||||
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
|
||||
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
|
||||
@ -180,7 +190,7 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row w-100 py-2 border-bottom small" v-for="r in check.Rules">
|
||||
<div v-for="r in check.Rules" :key="'rule_' + r.Name" class="row w-100 py-2 border-bottom small">
|
||||
<div class="col-2 col-lg-1">
|
||||
{{ r.Score }}
|
||||
</div>
|
||||
@ -195,25 +205,39 @@ export default {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutSpamAnalysis"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutSpamAnalysisLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutSpamAnalysisLabel">About Spam Analysis</h1>
|
||||
<h1 id="AboutSpamAnalysisLabel" class="modal-title fs-5">About Spam Analysis</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="SpamAnalysisAboutAccordion">
|
||||
<div id="SpamAnalysisAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is Spam Analysis?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit integrates with SpamAssassin to provide you with some insight into the
|
||||
@ -226,13 +250,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
How does the point system work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
|
||||
@ -248,18 +281,27 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
But I don't agree with the results...
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit does not manipulate the results nor determine the "spamminess" of
|
||||
your message. The result is what SpamAssassin returns, and it entirely
|
||||
dependent on how SpamAssassin is set up and optionally trained.
|
||||
Mailpit does not manipulate the results nor determine the "spamminess" of your
|
||||
message. The result is what SpamAssassin returns, and it entirely dependent on
|
||||
how SpamAssassin is set up and optionally trained.
|
||||
</p>
|
||||
<p>
|
||||
This tool is simply provided as an aid to assist you. If you are running your
|
||||
@ -271,20 +313,31 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
Where can I find more information about the triggered rules?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Unfortunately the current <a href="https://spamassassin.apache.org/"
|
||||
target="_blank">SpamAssassin website</a> no longer contains any relative
|
||||
documentation about these, most likely because the rules come from different
|
||||
locations and change often. You will need to search the internet for these
|
||||
yourself.
|
||||
Unfortunately the current
|
||||
<a href="https://spamassassin.apache.org/" target="_blank"
|
||||
>SpamAssassin website</a
|
||||
>
|
||||
no longer contains any relative documentation about these, most likely because
|
||||
the rules come from different locations and change often. You will need to
|
||||
search the internet for these yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,19 +1,18 @@
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
import ColorHash from 'color-hash'
|
||||
import { Modal, Offcanvas } from 'bootstrap'
|
||||
import axios from "axios";
|
||||
import dayjs from "dayjs";
|
||||
import ColorHash from "color-hash";
|
||||
import { Modal, Offcanvas } from "bootstrap";
|
||||
import { limitOptions } from "../stores/pagination";
|
||||
|
||||
// BootstrapElement is used to return a fake Bootstrap element
|
||||
// if the ID returns nothing to prevent errors.
|
||||
class BootstrapElement {
|
||||
constructor() { }
|
||||
hide() {}
|
||||
show() {}
|
||||
}
|
||||
|
||||
// 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 */
|
||||
export default {
|
||||
@ -21,89 +20,89 @@ export default {
|
||||
return {
|
||||
loading: 0,
|
||||
tagColorCache: {},
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
resolve(u) {
|
||||
return this.$router.resolve(u).href
|
||||
return this.$router.resolve(u).href;
|
||||
},
|
||||
|
||||
searchURI(s) {
|
||||
return this.resolve('/search') + '?q=' + encodeURIComponent(s)
|
||||
return this.resolve("/search") + "?q=" + encodeURIComponent(s);
|
||||
},
|
||||
|
||||
getFileSize(bytes) {
|
||||
if (bytes == 0) {
|
||||
return '0B'
|
||||
if (bytes === 0) {
|
||||
return "0B";
|
||||
}
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
|
||||
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];
|
||||
},
|
||||
|
||||
formatNumber(nr) {
|
||||
return new Intl.NumberFormat().format(nr)
|
||||
return new Intl.NumberFormat().format(nr);
|
||||
},
|
||||
|
||||
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) {
|
||||
return dayjs().subtract(d, 'seconds').fromNow()
|
||||
return dayjs().subtract(d, "seconds").fromNow();
|
||||
},
|
||||
|
||||
tagEncodeURI(tag) {
|
||||
if (tag.match(/ /)) {
|
||||
tag = `"${tag}"`
|
||||
tag = `"${tag}"`;
|
||||
}
|
||||
|
||||
return encodeURIComponent(`tag:${tag}`)
|
||||
return encodeURIComponent(`tag:${tag}`);
|
||||
},
|
||||
|
||||
getSearch() {
|
||||
if (!window.location.search) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const q = urlParams.get('q')?.trim()
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const q = urlParams.get("q")?.trim();
|
||||
if (!q) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
return q
|
||||
return q;
|
||||
},
|
||||
|
||||
getPaginationParams() {
|
||||
if (!window.location.search) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const start = parseInt(urlParams.get('start')?.trim(), 10)
|
||||
const limit = parseInt(urlParams.get('limit')?.trim(), 10)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const start = parseInt(urlParams.get("start")?.trim(), 10);
|
||||
const limit = parseInt(urlParams.get("limit")?.trim(), 10);
|
||||
return {
|
||||
start: Number.isInteger(start) && start >= 0 ? start : null,
|
||||
limit: limitOptions.includes(limit) ? limit : null,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// generic modal get/set function
|
||||
modal(id) {
|
||||
const e = document.getElementById(id)
|
||||
const e = document.getElementById(id);
|
||||
if (e) {
|
||||
return Modal.getOrCreateInstance(e)
|
||||
return Modal.getOrCreateInstance(e);
|
||||
}
|
||||
// in case there are open/close actions
|
||||
return new BootstrapElement()
|
||||
return new BootstrapElement();
|
||||
},
|
||||
|
||||
// close mobile navigation
|
||||
hideNav() {
|
||||
const e = document.getElementById('offcanvas')
|
||||
const e = document.getElementById("offcanvas");
|
||||
if (e) {
|
||||
Offcanvas.getOrCreateInstance(e).hide()
|
||||
Offcanvas.getOrCreateInstance(e).hide();
|
||||
}
|
||||
},
|
||||
|
||||
@ -117,23 +116,24 @@ export default {
|
||||
*/
|
||||
get(url, values, callback, errorCallback, hideLoader) {
|
||||
if (!hideLoader) {
|
||||
this.loading++
|
||||
this.loading++;
|
||||
}
|
||||
axios.get(url, { params: values })
|
||||
axios
|
||||
.get(url, { params: values })
|
||||
.then(callback)
|
||||
.catch((err) => {
|
||||
if (typeof errorCallback == 'function') {
|
||||
return errorCallback(err)
|
||||
if (typeof errorCallback === "function") {
|
||||
return errorCallback(err);
|
||||
}
|
||||
|
||||
this.handleError(err)
|
||||
this.handleError(err);
|
||||
})
|
||||
.then(() => {
|
||||
// always executed
|
||||
if (!hideLoader && this.loading > 0) {
|
||||
this.loading--
|
||||
this.loading--;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@ -144,16 +144,17 @@ export default {
|
||||
* @params function callback function
|
||||
*/
|
||||
post(url, data, callback) {
|
||||
this.loading++
|
||||
axios.post(url, data)
|
||||
this.loading++;
|
||||
axios
|
||||
.post(url, data)
|
||||
.then(callback)
|
||||
.catch(this.handleError)
|
||||
.then(() => {
|
||||
// always executed
|
||||
if (this.loading > 0) {
|
||||
this.loading--
|
||||
this.loading--;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@ -164,16 +165,17 @@ export default {
|
||||
* @params function callback function
|
||||
*/
|
||||
delete(url, data, callback) {
|
||||
this.loading++
|
||||
axios.delete(url, { data: data })
|
||||
this.loading++;
|
||||
axios
|
||||
.delete(url, { data })
|
||||
.then(callback)
|
||||
.catch(this.handleError)
|
||||
.then(() => {
|
||||
// always executed
|
||||
if (this.loading > 0) {
|
||||
this.loading--
|
||||
this.loading--;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@ -184,16 +186,17 @@ export default {
|
||||
* @params function callback function
|
||||
*/
|
||||
put(url, data, callback) {
|
||||
this.loading++
|
||||
axios.put(url, data)
|
||||
this.loading++;
|
||||
axios
|
||||
.put(url, data)
|
||||
.then(callback)
|
||||
.catch(this.handleError)
|
||||
.then(() => {
|
||||
// always executed
|
||||
if (this.loading > 0) {
|
||||
this.loading--
|
||||
this.loading--;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
// Ajax error message
|
||||
@ -203,87 +206,87 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
alert(error.response.data.Error)
|
||||
alert(error.response.data.Error);
|
||||
} else {
|
||||
alert(error.response.data)
|
||||
alert(error.response.data);
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 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 {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
alert(error.message)
|
||||
alert(error.message);
|
||||
}
|
||||
},
|
||||
|
||||
allAttachments(message) {
|
||||
let a = []
|
||||
for (let i in message.Attachments) {
|
||||
a.push(message.Attachments[i])
|
||||
const a = [];
|
||||
for (const i in message.Attachments) {
|
||||
a.push(message.Attachments[i]);
|
||||
}
|
||||
for (let i in message.OtherParts) {
|
||||
a.push(message.OtherParts[i])
|
||||
for (const i in message.OtherParts) {
|
||||
a.push(message.OtherParts[i]);
|
||||
}
|
||||
for (let i in message.Inline) {
|
||||
a.push(message.Inline[i])
|
||||
for (const i in message.Inline) {
|
||||
a.push(message.Inline[i]);
|
||||
}
|
||||
|
||||
return a.length ? a : false
|
||||
return a.length ? a : false;
|
||||
},
|
||||
|
||||
isImage(a) {
|
||||
return a.ContentType.match(/^image\//)
|
||||
return a.ContentType.match(/^image\//);
|
||||
},
|
||||
|
||||
attachmentIcon(a) {
|
||||
let ext = a.FileName.split('.').pop().toLowerCase()
|
||||
const ext = a.FileName.split(".").pop().toLowerCase();
|
||||
|
||||
if (a.ContentType.match(/^image\//)) {
|
||||
return 'bi-file-image-fill'
|
||||
return "bi-file-image-fill";
|
||||
}
|
||||
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') {
|
||||
return 'bi-file-pdf-fill'
|
||||
if (a.ContentType.match(/\/pdf$/) || ext === "pdf") {
|
||||
return "bi-file-pdf-fill";
|
||||
}
|
||||
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
|
||||
return 'bi-file-word-fill'
|
||||
if (["doc", "docx", "odt", "rtf"].includes(ext)) {
|
||||
return "bi-file-word-fill";
|
||||
}
|
||||
if (['xls', 'xlsx', 'ods'].includes(ext)) {
|
||||
return 'bi-file-spreadsheet-fill'
|
||||
if (["xls", "xlsx", "ods"].includes(ext)) {
|
||||
return "bi-file-spreadsheet-fill";
|
||||
}
|
||||
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
|
||||
return 'bi-file-slides-fill'
|
||||
if (["ppt", "pptx", "key", "ppt", "odp"].includes(ext)) {
|
||||
return "bi-file-slides-fill";
|
||||
}
|
||||
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
|
||||
return 'bi-file-zip-fill'
|
||||
if (["zip", "tar", "rar", "bz2", "gz", "xz"].includes(ext)) {
|
||||
return "bi-file-zip-fill";
|
||||
}
|
||||
if (['ics'].includes(ext)) {
|
||||
return 'bi-calendar-event'
|
||||
if (["ics"].includes(ext)) {
|
||||
return "bi-calendar-event";
|
||||
}
|
||||
if (a.ContentType.match(/^audio\//)) {
|
||||
return 'bi-file-music-fill'
|
||||
return "bi-file-music-fill";
|
||||
}
|
||||
if (a.ContentType.match(/^video\//)) {
|
||||
return 'bi-file-play-fill'
|
||||
return "bi-file-play-fill";
|
||||
}
|
||||
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)) {
|
||||
return 'bi-file-text-fill'
|
||||
if (a.ContentType.match(/^text\//) || ["txt", "sh", "log"].includes(ext)) {
|
||||
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.
|
||||
// Values are stored in an array for faster lookup / processing.
|
||||
colorHash(s) {
|
||||
if (this.tagColorCache[s] != undefined) {
|
||||
return this.tagColorCache[s]
|
||||
if (this.tagColorCache[s] !== undefined) {
|
||||
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 { mailbox } from '../stores/mailbox.js'
|
||||
import { pagination } from '../stores/pagination.js'
|
||||
import CommonMixins from "./CommonMixins.js";
|
||||
import { mailbox } from "../stores/mailbox.js";
|
||||
import { pagination } from "../stores/pagination.js";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
@ -10,88 +10,86 @@ export default {
|
||||
apiURI: false,
|
||||
pagination,
|
||||
mailbox,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
'mailbox.refresh': function (v) {
|
||||
"mailbox.refresh": function (v) {
|
||||
if (v) {
|
||||
// trigger a refresh
|
||||
this.loadMessages()
|
||||
}
|
||||
|
||||
mailbox.refresh = false
|
||||
this.loadMessages();
|
||||
}
|
||||
mailbox.refresh = false;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
reloadMailbox() {
|
||||
pagination.start = 0
|
||||
this.loadMessages()
|
||||
pagination.start = 0;
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
loadMessages() {
|
||||
if (!this.apiURI) {
|
||||
alert('apiURL not set!')
|
||||
return
|
||||
alert("apiURL not set!");
|
||||
return;
|
||||
}
|
||||
|
||||
// auto-pagination changes the URL but should not fetch new messages
|
||||
// when viewing page > 0 and new messages are received (inbox only)
|
||||
if (!mailbox.autoPaginating) {
|
||||
mailbox.autoPaginating = true // reset
|
||||
return
|
||||
mailbox.autoPaginating = true; // reset
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {}
|
||||
mailbox.selected = []
|
||||
const params = {};
|
||||
mailbox.selected = [];
|
||||
|
||||
params['limit'] = pagination.limit
|
||||
params["limit"] = pagination.limit;
|
||||
if (pagination.start > 0) {
|
||||
params['start'] = pagination.start
|
||||
params["start"] = pagination.start;
|
||||
}
|
||||
|
||||
this.get(this.apiURI, params, (response) => {
|
||||
mailbox.total = response.data.total // all messages
|
||||
mailbox.unread = response.data.unread // all unread messages
|
||||
mailbox.tags = response.data.tags // all tags
|
||||
mailbox.messages = response.data.messages // current messages
|
||||
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.total = response.data.total; // all messages
|
||||
mailbox.unread = response.data.unread; // all unread messages
|
||||
mailbox.tags = response.data.tags; // all tags
|
||||
mailbox.messages = response.data.messages; // current messages
|
||||
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
|
||||
// ensure the pagination remains consistent
|
||||
pagination.start = response.data.start
|
||||
pagination.start = response.data.start;
|
||||
|
||||
if (response.data.count == 0 && response.data.start > 0) {
|
||||
pagination.start = 0
|
||||
return this.loadMessages()
|
||||
if (response.data.count === 0 && response.data.start > 0) {
|
||||
pagination.start = 0;
|
||||
return this.loadMessages();
|
||||
}
|
||||
|
||||
if (mailbox.lastMessage) {
|
||||
window.setTimeout(() => {
|
||||
const m = document.getElementById(mailbox.lastMessage)
|
||||
const m = document.getElementById(mailbox.lastMessage);
|
||||
if (m) {
|
||||
m.focus()
|
||||
m.focus();
|
||||
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
m.scrollIntoView({ block: 'center' })
|
||||
m.scrollIntoView({ block: "center" });
|
||||
} else {
|
||||
const mp = document.getElementById('message-page')
|
||||
const mp = document.getElementById("message-page");
|
||||
if (mp) {
|
||||
mp.scrollTop = 0
|
||||
mp.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
mailbox.lastMessage = false
|
||||
}, 50)
|
||||
|
||||
mailbox.lastMessage = false;
|
||||
}, 50);
|
||||
} else if (!window.scrollInPlace) {
|
||||
const mp = document.getElementById('message-page')
|
||||
const mp = document.getElementById("message-page");
|
||||
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 MailboxView from '../views/MailboxView.vue'
|
||||
import MessageView from '../views/MessageView.vue'
|
||||
import NotFoundView from '../views/NotFoundView.vue'
|
||||
import SearchView from '../views/SearchView.vue'
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import MailboxView from "../views/MailboxView.vue";
|
||||
import MessageView from "../views/MessageView.vue";
|
||||
import NotFoundView from "../views/NotFoundView.vue";
|
||||
import SearchView from "../views/SearchView.vue";
|
||||
|
||||
let d = document.getElementById('app')
|
||||
let webroot = '/'
|
||||
const d = document.getElementById("app");
|
||||
let webroot = "/";
|
||||
if (d) {
|
||||
webroot = d.dataset.webroot
|
||||
webroot = d.dataset.webroot;
|
||||
}
|
||||
|
||||
// paths are relative to webroot
|
||||
@ -15,23 +15,23 @@ const router = createRouter({
|
||||
history: createWebHistory(webroot),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: MailboxView
|
||||
path: "/",
|
||||
component: MailboxView,
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
component: SearchView
|
||||
path: "/search",
|
||||
component: SearchView,
|
||||
},
|
||||
{
|
||||
path: '/view/:id',
|
||||
component: MessageView
|
||||
path: "/view/:id",
|
||||
component: MessageView,
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: NotFoundView
|
||||
}
|
||||
]
|
||||
})
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "NotFound",
|
||||
component: NotFoundView,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
@ -1,6 +1,6 @@
|
||||
// State Management
|
||||
|
||||
import { reactive, watch } from 'vue'
|
||||
import { reactive, watch } from "vue";
|
||||
|
||||
// global mailbox info
|
||||
export const mailbox = reactive({
|
||||
@ -22,71 +22,73 @@ export const mailbox = reactive({
|
||||
lastMessage: false, // return scrolling
|
||||
|
||||
// settings
|
||||
showTagColors: !localStorage.getItem('hideTagColors') == '1',
|
||||
showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1',
|
||||
showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1',
|
||||
showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1',
|
||||
timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
showTagColors: !localStorage.getItem("hideTagColors"),
|
||||
showHTMLCheck: !localStorage.getItem("hideHTMLCheck"),
|
||||
showLinkCheck: !localStorage.getItem("hideLinkCheck"),
|
||||
showSpamCheck: !localStorage.getItem("hideSpamCheck"),
|
||||
timeZone: localStorage.getItem("timeZone")
|
||||
? localStorage.getItem("timeZone")
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => mailbox.count,
|
||||
(v) => {
|
||||
mailbox.selected = []
|
||||
}
|
||||
)
|
||||
mailbox.selected = [];
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => mailbox.showTagColors,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.removeItem('hideTagColors')
|
||||
localStorage.removeItem("hideTagColors");
|
||||
} else {
|
||||
localStorage.setItem('hideTagColors', '1')
|
||||
localStorage.setItem("hideTagColors", "1");
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => mailbox.showHTMLCheck,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.removeItem('hideHTMLCheck')
|
||||
localStorage.removeItem("hideHTMLCheck");
|
||||
} else {
|
||||
localStorage.setItem('hideHTMLCheck', '1')
|
||||
localStorage.setItem("hideHTMLCheck", "1");
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => mailbox.showLinkCheck,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.removeItem('hideLinkCheck')
|
||||
localStorage.removeItem("hideLinkCheck");
|
||||
} else {
|
||||
localStorage.setItem('hideLinkCheck', '1')
|
||||
localStorage.setItem("hideLinkCheck", "1");
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => mailbox.showSpamCheck,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.removeItem('hideSpamCheck')
|
||||
localStorage.removeItem("hideSpamCheck");
|
||||
} else {
|
||||
localStorage.setItem('hideSpamCheck', '1')
|
||||
localStorage.setItem("hideSpamCheck", "1");
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => mailbox.timeZone,
|
||||
(v) => {
|
||||
if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) {
|
||||
localStorage.removeItem('timeZone')
|
||||
if (v === Intl.DateTimeFormat().resolvedOptions().timeZone) {
|
||||
localStorage.removeItem("timeZone");
|
||||
} else {
|
||||
localStorage.setItem('timeZone', v)
|
||||
localStorage.setItem("timeZone", v);
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { reactive } from 'vue'
|
||||
import { reactive } from "vue";
|
||||
|
||||
export const pagination = reactive({
|
||||
start: 0, // pagination offset
|
||||
@ -6,6 +6,6 @@ export const pagination = reactive({
|
||||
defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit
|
||||
total: 0, // total results of current view / filter
|
||||
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>
|
||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import ListMessages from '../components/ListMessages.vue'
|
||||
import MessagesMixins from '../mixins/MessagesMixins'
|
||||
import NavMailbox from '../components/NavMailbox.vue'
|
||||
import NavTags from '../components/NavTags.vue'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import SearchForm from '../components/SearchForm.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import About from "../components/AppAbout.vue";
|
||||
import AjaxLoader from "../components/AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import ListMessages from "../components/ListMessages.vue";
|
||||
import MessagesMixins from "../mixins/MessagesMixins";
|
||||
import NavMailbox from "../components/NavMailbox.vue";
|
||||
import NavTags from "../components/NavTags.vue";
|
||||
import Pagination from "../components/NavPagination.vue";
|
||||
import SearchForm from "../components/SearchForm.vue";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
About,
|
||||
AjaxLoader,
|
||||
ListMessages,
|
||||
NavMailbox,
|
||||
@ -27,111 +22,119 @@ export default {
|
||||
SearchForm,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
delayedRefresh: false,
|
||||
paginationDelayed: false, // for delayed pagination URL changes
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.loadMailbox()
|
||||
}
|
||||
this.loadMailbox();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
mailbox.searching = false
|
||||
this.apiURI = this.resolve(`/api/v1/messages`)
|
||||
this.loadMailbox()
|
||||
mailbox.searching = false;
|
||||
this.apiURI = this.resolve(`/api/v1/messages`);
|
||||
this.loadMailbox();
|
||||
|
||||
// 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)
|
||||
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)
|
||||
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: {
|
||||
loadMailbox() {
|
||||
const paginationParams = this.getPaginationParams()
|
||||
const paginationParams = this.getPaginationParams();
|
||||
if (paginationParams?.start) {
|
||||
pagination.start = paginationParams.start
|
||||
pagination.start = paginationParams.start;
|
||||
} else {
|
||||
pagination.start = 0
|
||||
pagination.start = 0;
|
||||
}
|
||||
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
|
||||
// when viewing the inbox on > page 1, while receiving an influx of new messages.
|
||||
delayedPaginationUpdate() {
|
||||
if (this.paginationDelayed) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this.paginationDelayed = true
|
||||
this.paginationDelayed = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
const path = this.$route.path
|
||||
const path = this.$route.path;
|
||||
const p = {
|
||||
...this.$route.query
|
||||
}
|
||||
...this.$route.query,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
p.start = pagination.start.toString();
|
||||
} else {
|
||||
delete p.start
|
||||
delete p.start;
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
} else {
|
||||
delete p.limit
|
||||
delete p.limit;
|
||||
}
|
||||
|
||||
mailbox.autoPaginating = false // prevent reload of messages when URL changes
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.replace(path + '?' + params.toString())
|
||||
mailbox.autoPaginating = false; // prevent reload of messages when URL changes
|
||||
const params = new URLSearchParams(p);
|
||||
this.$router.replace(path + "?" + params.toString());
|
||||
|
||||
this.paginationDelayed = false
|
||||
}, 500)
|
||||
this.paginationDelayed = false;
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// handler for websocket new messages
|
||||
handleWSNew(data) {
|
||||
if (pagination.start < 1) {
|
||||
// push results directly into first page
|
||||
mailbox.messages.unshift(data)
|
||||
mailbox.messages.unshift(data);
|
||||
if (mailbox.messages.length > pagination.limit) {
|
||||
mailbox.messages.pop()
|
||||
mailbox.messages.pop();
|
||||
}
|
||||
} else {
|
||||
// update pagination offset
|
||||
pagination.start++
|
||||
pagination.start++;
|
||||
// prevent "Too many calls to Location or History APIs within a short time frame"
|
||||
this.delayedPaginationUpdate()
|
||||
this.delayedPaginationUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
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
|
||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||
return
|
||||
this.mailbox.messages[x] = {
|
||||
...this.mailbox.messages[x],
|
||||
...data,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -140,43 +143,43 @@ export default {
|
||||
handleWSDelete(data) {
|
||||
let removed = 0;
|
||||
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
|
||||
this.mailbox.messages.splice(x, 1)
|
||||
removed++
|
||||
continue
|
||||
this.mailbox.messages.splice(x, 1);
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed || this.delayedRefresh) {
|
||||
// nothing changed on this screen, or a refresh is queued,
|
||||
// don't refresh
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||
this.delayedRefresh = true
|
||||
this.delayedRefresh = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.delayedRefresh = false
|
||||
this.loadMessages()
|
||||
}, 500)
|
||||
this.delayedRefresh = false;
|
||||
this.loadMessages();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages gone, reload
|
||||
this.loadMessages()
|
||||
this.loadMessages();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
@ -185,8 +188,13 @@ export default {
|
||||
</div>
|
||||
<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">
|
||||
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
|
||||
data-bs-target="#offcanvas" aria-controls="offcanvas">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
@ -194,20 +202,30 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
|
||||
aria-labelledby="offcanvasLabel">
|
||||
<div
|
||||
id="offcanvas"
|
||||
class="offcanvas-md offcanvas-start d-md-none"
|
||||
data-bs-scroll="true"
|
||||
tabindex="-1"
|
||||
aria-labelledby="offcanvasLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="offcanvas"
|
||||
data-bs-target="#offcanvas"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavMailbox @load-messages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -215,20 +233,20 @@ export default {
|
||||
<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="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavMailbox @load-messages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
<About />
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavMailbox @loadMessages="loadMessages" modals />
|
||||
<AboutMailpit modals />
|
||||
<NavMailbox modals @load-messages="loadMessages" />
|
||||
<About modals />
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
|
@ -1,20 +1,15 @@
|
||||
<script>
|
||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import Message from '../components/message/Message.vue'
|
||||
import Release from '../components/message/Release.vue'
|
||||
import Screenshot from '../components/message/Screenshot.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import dayjs from 'dayjs'
|
||||
import AboutMailpit from "../components/AppAbout.vue";
|
||||
import AjaxLoader from "../components/AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import Message from "../components/message/MessageItem.vue";
|
||||
import Release from "../components/message/MessageRelease.vue";
|
||||
import Screenshot from "../components/message/MessageScreenshot.vue";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@ -23,6 +18,11 @@ export default {
|
||||
Release,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
@ -36,203 +36,206 @@ export default {
|
||||
liveLoaded: 0, // the number new messages prepended tp messageList
|
||||
scrollLoading: false,
|
||||
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: {
|
||||
// get current message read status
|
||||
isRead() {
|
||||
const l = this.messagesList.length
|
||||
const l = this.messagesList.length;
|
||||
if (!this.message || !l) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return this.messagesList[x].Read
|
||||
for (let x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID === this.message.ID) {
|
||||
return this.messagesList[x].Read;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
},
|
||||
|
||||
// get the previous message ID
|
||||
previousID() {
|
||||
const l = this.messagesList.length
|
||||
const l = this.messagesList.length;
|
||||
if (!this.message || !l) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return id
|
||||
let id = false;
|
||||
for (let x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID === this.message.ID) {
|
||||
return id;
|
||||
}
|
||||
id = this.messagesList[x].ID
|
||||
id = this.messagesList[x].ID;
|
||||
}
|
||||
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
|
||||
// get the next message ID
|
||||
nextID() {
|
||||
const l = this.messagesList.length
|
||||
const l = this.messagesList.length;
|
||||
if (!this.message || !l) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = l - 1; x > 0; x--) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return id
|
||||
let id = false;
|
||||
for (let x = l - 1; x > 0; x--) {
|
||||
if (this.messagesList[x].ID === this.message.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: {
|
||||
loadMessage() {
|
||||
this.message = false
|
||||
const uri = this.resolve('/api/v1/message/' + this.$route.params.id)
|
||||
this.get(uri, false, (response) => {
|
||||
this.errorMessage = false
|
||||
const d = response.data
|
||||
this.message = false;
|
||||
const uri = this.resolve("/api/v1/message/" + this.$route.params.id);
|
||||
this.get(
|
||||
uri,
|
||||
false,
|
||||
(response) => {
|
||||
this.errorMessage = false;
|
||||
const d = response.data;
|
||||
|
||||
// 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
|
||||
if (d.HTML && d.Inline) {
|
||||
for (let i in d.Inline) {
|
||||
let a = d.Inline[i]
|
||||
if (a.ContentID != '') {
|
||||
for (const i in d.Inline) {
|
||||
const a = d.Inline[i];
|
||||
if (a.ContentID !== "") {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||
"$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
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replace inline images embedded as regular attachments
|
||||
if (d.HTML && d.Attachments) {
|
||||
for (let i in d.Attachments) {
|
||||
let a = d.Attachments[i]
|
||||
if (a.ContentID != '') {
|
||||
for (const i in d.Attachments) {
|
||||
const a = d.Attachments[i];
|
||||
if (a.ContentID !== "") {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||
"$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
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.message = d
|
||||
this.message = d;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.scrollSidebarToCurrent()
|
||||
})
|
||||
this.scrollSidebarToCurrent();
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
this.errorMessage = true
|
||||
this.errorMessage = true;
|
||||
if (error.response && error.response.data) {
|
||||
if (error.response.data.Error) {
|
||||
this.errorMessage = error.response.data.Error
|
||||
this.errorMessage = error.response.data.Error;
|
||||
} else {
|
||||
this.errorMessage = error.response.data
|
||||
this.errorMessage = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 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 {
|
||||
// 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
|
||||
refreshUI() {
|
||||
window.setTimeout(() => {
|
||||
this.$forceUpdate()
|
||||
this.refreshUI()
|
||||
}, 30000)
|
||||
this.$forceUpdate();
|
||||
this.refreshUI();
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
// handler for websocket new messages
|
||||
handleWSNew(data) {
|
||||
// do not add when searching or >= 100 new messages have been received
|
||||
if (this.mailbox.searching || this.liveLoaded >= 100) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this.liveLoaded++
|
||||
this.messagesList.unshift(data)
|
||||
this.liveLoaded++;
|
||||
this.messagesList.unshift(data);
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
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
|
||||
this.messagesList[x] = { ...this.messagesList[x], ...data }
|
||||
return
|
||||
this.messagesList[x] = { ...this.messagesList[x], ...data };
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -240,10 +243,10 @@ export default {
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
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
|
||||
this.messagesList.splice(x, 1)
|
||||
return
|
||||
this.messagesList.splice(x, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -251,277 +254,299 @@ export default {
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages gone, go to inbox
|
||||
this.$router.push('/')
|
||||
this.$router.push("/");
|
||||
},
|
||||
|
||||
// return whether the sidebar is visible
|
||||
sidebarVisible() {
|
||||
return this.$refs.MessageList.offsetParent != null
|
||||
return this.$refs.MessageList.offsetParent !== null;
|
||||
},
|
||||
|
||||
// scroll sidenav to current message if found
|
||||
scrollSidebarToCurrent() {
|
||||
const cont = document.getElementById('MessageList')
|
||||
const cont = document.getElementById("MessageList");
|
||||
if (!cont) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const c = cont.querySelector('.router-link-active')
|
||||
const c = cont.querySelector(".router-link-active");
|
||||
if (c) {
|
||||
const outer = cont.getBoundingClientRect()
|
||||
const li = c.getBoundingClientRect()
|
||||
const outer = cont.getBoundingClientRect();
|
||||
const li = c.getBoundingClientRect();
|
||||
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) {
|
||||
if (!this.canLoadMore || this.scrollLoading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, offsetHeight, scrollHeight } = e.target
|
||||
if ((scrollTop + offsetHeight + 150) >= scrollHeight) {
|
||||
this.loadMore()
|
||||
const { scrollTop, offsetHeight, scrollHeight } = e.target;
|
||||
if (scrollTop + offsetHeight + 150 >= scrollHeight) {
|
||||
this.loadMore();
|
||||
}
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
if (this.messagesList.length) {
|
||||
// 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>`
|
||||
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(
|
||||
this.apiSideNavURI,
|
||||
this.apiSideNavParams,
|
||||
(response) => {
|
||||
if (response.data.messages.length) {
|
||||
this.messagesList.push(...response.data.messages)
|
||||
this.messagesList.push(...response.data.messages);
|
||||
} else {
|
||||
this.canLoadMore = false
|
||||
this.canLoadMore = false;
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.scrollLoading = false
|
||||
})
|
||||
}, null, true)
|
||||
this.scrollLoading = false;
|
||||
});
|
||||
},
|
||||
null,
|
||||
true,
|
||||
);
|
||||
},
|
||||
|
||||
initLoadMoreAPIParams() {
|
||||
let apiURI = this.resolve(`/api/v1/messages`)
|
||||
let p = {}
|
||||
let apiURI = this.resolve(`/api/v1/messages`);
|
||||
const p = {};
|
||||
|
||||
if (mailbox.searching) {
|
||||
apiURI = this.resolve(`/api/v1/search`)
|
||||
p.query = mailbox.searching
|
||||
apiURI = this.resolve(`/api/v1/search`);
|
||||
p.query = mailbox.searching;
|
||||
}
|
||||
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
|
||||
this.apiSideNavURI = apiURI
|
||||
this.apiSideNavURI = apiURI;
|
||||
|
||||
this.apiSideNavParams = new URLSearchParams(p)
|
||||
this.apiSideNavParams = new URLSearchParams(p);
|
||||
},
|
||||
|
||||
getRelativeCreated(message) {
|
||||
const d = new Date(message.Created)
|
||||
return dayjs(d).fromNow()
|
||||
const d = new Date(message.Created);
|
||||
return dayjs(d).fromNow();
|
||||
},
|
||||
|
||||
getPrimaryEmailTo(message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
if (message.To && message.To.length > 0) {
|
||||
return message.To[0].Address;
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
return "[ Undisclosed recipients ]";
|
||||
},
|
||||
|
||||
isActive(id) {
|
||||
return this.message.ID == id
|
||||
return this.message.ID === id;
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
t = `"${t}"`;
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
q: "tag:" + t,
|
||||
};
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
const params = new URLSearchParams(p);
|
||||
return "/search?" + params.toString();
|
||||
},
|
||||
|
||||
downloadMessageBody(str, ext) {
|
||||
const dl = document.createElement('a')
|
||||
dl.href = "data:text/plain," + encodeURIComponent(str)
|
||||
dl.target = '_blank'
|
||||
dl.download = this.message.ID + '.' + ext
|
||||
dl.click()
|
||||
const dl = document.createElement("a");
|
||||
dl.href = "data:text/plain," + encodeURIComponent(str);
|
||||
dl.target = "_blank";
|
||||
dl.download = this.message.ID + "." + ext;
|
||||
dl.click();
|
||||
},
|
||||
|
||||
screenshotMessageHTML() {
|
||||
this.$refs.ScreenshotRef.initScreenshot()
|
||||
this.$refs.ScreenshotRef.initScreenshot();
|
||||
},
|
||||
|
||||
// toggle current message read status
|
||||
toggleRead() {
|
||||
if (!this.message) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
const read = !this.isRead
|
||||
const read = !this.isRead;
|
||||
|
||||
const ids = [this.message.ID]
|
||||
const uri = this.resolve('/api/v1/messages')
|
||||
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
|
||||
const ids = [this.message.ID];
|
||||
const uri = this.resolve("/api/v1/messages");
|
||||
this.put(uri, { Read: read, IDs: ids }, () => {
|
||||
if (!this.sidebarVisible()) {
|
||||
return this.goBack()
|
||||
return this.goBack();
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const ids = [this.message.ID]
|
||||
const uri = this.resolve('/api/v1/messages')
|
||||
const ids = [this.message.ID];
|
||||
const uri = this.resolve("/api/v1/messages");
|
||||
// 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()) {
|
||||
return this.goBack()
|
||||
return this.goBack();
|
||||
}
|
||||
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
|
||||
goBack() {
|
||||
mailbox.lastMessage = this.$route.params.id
|
||||
mailbox.lastMessage = this.$route.params.id;
|
||||
|
||||
if (mailbox.searching) {
|
||||
const p = {
|
||||
q: mailbox.searching
|
||||
}
|
||||
q: mailbox.searching,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
p.start = pagination.start.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
this.$router.push('/search?' + new URLSearchParams(p).toString())
|
||||
this.$router.push("/search?" + new URLSearchParams(p).toString());
|
||||
} else {
|
||||
const p = {}
|
||||
const p = {};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
p.start = pagination.start.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
this.$router.push('/?' + new URLSearchParams(p).toString())
|
||||
this.$router.push("/?" + new URLSearchParams(p).toString());
|
||||
}
|
||||
},
|
||||
|
||||
reloadWindow() {
|
||||
location.reload()
|
||||
location.reload();
|
||||
},
|
||||
|
||||
initReleaseModal() {
|
||||
this.modal('ReleaseModal').show()
|
||||
this.modal("ReleaseModal").show();
|
||||
window.setTimeout(() => {
|
||||
// delay to allow elements to load / focus
|
||||
this.$refs.ReleaseRef.initTags()
|
||||
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
|
||||
}, 500)
|
||||
this.$refs.ReleaseRef.initTags();
|
||||
document.querySelector('#ReleaseModal input[role="combobox"]').focus();
|
||||
}, 500);
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col col-xl-5" v-if="!errorMessage">
|
||||
<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages">
|
||||
<div v-if="!errorMessage" class="col col-xl-5">
|
||||
<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>
|
||||
<span class="ms-2 d-none d-lg-inline">Back</span>
|
||||
</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>
|
||||
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
|
||||
</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-on:click="initReleaseModal()">
|
||||
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
|
||||
class="btn btn-outline-light me-1 me-sm-2"
|
||||
title="Release message"
|
||||
@click="initReleaseModal()"
|
||||
>
|
||||
<i class="bi bi-send"></i>
|
||||
<span class="d-none d-md-inline">Release</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()">
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto col-lg-4 col-xl-4 text-end" v-if="!errorMessage">
|
||||
<div class="dropdown d-inline-block" id="DownloadBtn">
|
||||
<button type="button" class="btn btn-outline-light dropdown-toggle" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<div v-if="!errorMessage" class="col-auto col-lg-4 col-xl-4 text-end">
|
||||
<div id="DownloadBtn" class="dropdown d-inline-block">
|
||||
<button
|
||||
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>
|
||||
<span class="d-none d-md-inline ms-1">Download</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" class="dropdown-item"
|
||||
title="Message source including headers, body and attachments">
|
||||
<a
|
||||
:href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')"
|
||||
class="dropdown-item"
|
||||
title="Message source including headers, body and attachments"
|
||||
>
|
||||
Raw message
|
||||
</a>
|
||||
</li>
|
||||
<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
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="message.HTML">
|
||||
<button class="dropdown-item" @click="screenshotMessageHTML()">
|
||||
HTML screenshot
|
||||
</button>
|
||||
<button class="dropdown-item" @click="screenshotMessageHTML()">HTML screenshot</button>
|
||||
</li>
|
||||
<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
|
||||
</button>
|
||||
</li>
|
||||
<template v-if="message.Attachments && message.Attachments.length">
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
<hr class="dropdown-divider" />
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">
|
||||
Attachments
|
||||
</h6>
|
||||
<h6 class="dropdown-header">Attachments</h6>
|
||||
</li>
|
||||
<li v-for="part in message.Attachments">
|
||||
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||
<li v-for="part in message.Attachments" :key="part.PartID">
|
||||
<RouterLink
|
||||
:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||
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">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="col text-truncate p-0 pe-1">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
|
||||
</div>
|
||||
<div class="col-auto text-muted small p-0">
|
||||
{{ getFileSize(part.Size) }}
|
||||
@ -531,22 +556,24 @@ export default {
|
||||
</template>
|
||||
<template v-if="message.Inline && message.Inline.length">
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
<hr class="dropdown-divider" />
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">
|
||||
Inline image<span v-if="message.Inline.length > 1">s</span>
|
||||
</h6>
|
||||
<h6 class="dropdown-header">Inline image<span v-if="message.Inline.length > 1">s</span></h6>
|
||||
</li>
|
||||
<li v-for="part in message.Inline">
|
||||
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||
<li v-for="part in message.Inline" :key="part.PartID">
|
||||
<RouterLink
|
||||
:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||
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">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="col text-truncate p-0 pe-1">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
|
||||
</div>
|
||||
<div class="col-auto text-muted small p-0">
|
||||
{{ getFileSize(part.Size) }}
|
||||
@ -557,8 +584,12 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="previousID ? '' : 'disabled'" title="View previous message">
|
||||
<RouterLink
|
||||
: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>
|
||||
</RouterLink>
|
||||
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
|
||||
@ -569,67 +600,87 @@ export default {
|
||||
|
||||
<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="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
|
||||
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
<button @click="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>
|
||||
<span class="ms-1">
|
||||
Return to
|
||||
<template v-if="mailbox.searching">search</template>
|
||||
<template v-else>inbox</template>
|
||||
</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread && !errorMessage">
|
||||
<span
|
||||
v-if="mailbox.unread && !errorMessage"
|
||||
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||
title="Unread messages"
|
||||
>
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
|
||||
@scroll="scrollHandler">
|
||||
<div
|
||||
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()">
|
||||
Reload to see newer messages
|
||||
</button>
|
||||
<template v-if="messagesList && messagesList.length">
|
||||
<div class="list-group">
|
||||
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
|
||||
:id="message.ID"
|
||||
<RouterLink
|
||||
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="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
|
||||
:class="[summary.Read ? 'read' : '', isActive(summary.ID) ? 'active' : '']"
|
||||
>
|
||||
<div class="col overflow-x-hidden">
|
||||
<div class="text-truncate privacy small">
|
||||
<strong v-if="message.From" :title="'From: ' + message.From.Address">
|
||||
{{ message.From.Name ? message.From.Name : message.From.Address }}
|
||||
<strong v-if="summary.From" :title="'From: ' + summary.From.Address">
|
||||
{{ summary.From.Name ? summary.From.Name : summary.From.Address }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto small">
|
||||
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
<i v-if="summary.Attachments" class="bi bi-paperclip h6"></i>
|
||||
{{ getRelativeCreated(summary) }}
|
||||
</div>
|
||||
<div class="col-12 overflow-x-hidden">
|
||||
<div class="text-truncate privacy small">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
To: {{ getPrimaryEmailTo(summary) }}
|
||||
<span v-if="summary.To && summary.To.length > 1">
|
||||
[+{{ summary.To.length - 1 }}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 overflow-x-hidden mt-1">
|
||||
<div class="text-truncates small">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
<b>{{ summary.Subject !== "" ? summary.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.Tags.length" class="col-12">
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
||||
v-on:click="pagination.start = 0"
|
||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||
:title="'Filter messages tagged with ' + t">
|
||||
<div v-if="summary.Tags.length" class="col-12">
|
||||
<RouterLink
|
||||
v-for="t in summary.Tags"
|
||||
:key="t"
|
||||
class="badge me-1"
|
||||
:to="toTagUrl(t)"
|
||||
:style="
|
||||
mailbox.showTagColors
|
||||
? { backgroundColor: colorHash(t) }
|
||||
: { backgroundColor: '#6c757d' }
|
||||
"
|
||||
:title="'Filter messages tagged with ' + t"
|
||||
@click="pagination.start = 0"
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
@ -642,7 +693,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3 class="text-center my-3">
|
||||
{{ errorMessage }}
|
||||
@ -655,7 +706,11 @@ export default {
|
||||
|
||||
<AboutMailpit modals />
|
||||
<AjaxLoader :loading="loading" />
|
||||
<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message"
|
||||
@delete="deleteMessage" />
|
||||
<Release
|
||||
v-if="mailbox.uiConfig.MessageRelay && message"
|
||||
ref="ReleaseRef"
|
||||
:message="message"
|
||||
@delete="deleteMessage"
|
||||
/>
|
||||
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
|
||||
</template>
|
||||
|
@ -1,21 +1,21 @@
|
||||
<script>
|
||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import About from "../components/AppAbout.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
About,
|
||||
},
|
||||
}
|
||||
|
||||
mixins: [CommonMixins],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white">
|
||||
<div class="d-block text-center">
|
||||
<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>Click here to continue</p>
|
||||
@ -23,7 +23,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<div class="d-none">
|
||||
<AboutMailpit />
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -1,24 +1,19 @@
|
||||
<script>
|
||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import ListMessages from '../components/ListMessages.vue'
|
||||
import MessagesMixins from '../mixins/MessagesMixins'
|
||||
import NavSearch from '../components/NavSearch.vue'
|
||||
import NavTags from '../components/NavTags.vue'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import SearchForm from '../components/SearchForm.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import About from "../components/AppAbout.vue";
|
||||
import AjaxLoader from "../components/AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import ListMessages from "../components/ListMessages.vue";
|
||||
import MessagesMixins from "../mixins/MessagesMixins";
|
||||
import NavSearch from "../components/NavSearch.vue";
|
||||
import NavTags from "../components/NavTags.vue";
|
||||
import Pagination from "../components/NavPagination.vue";
|
||||
import SearchForm from "../components/SearchForm.vue";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
About,
|
||||
AjaxLoader,
|
||||
ListMessages,
|
||||
NavSearch,
|
||||
@ -27,63 +22,68 @@ export default {
|
||||
SearchForm,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
delayedRefresh: false,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.doSearch()
|
||||
}
|
||||
this.doSearch();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
mailbox.searching = this.getSearch()
|
||||
this.doSearch()
|
||||
mailbox.searching = this.getSearch();
|
||||
this.doSearch();
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
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("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
this.eventBus.off("update", this.handleWSUpdate);
|
||||
this.eventBus.off("delete", this.handleWSDelete);
|
||||
this.eventBus.off("truncate", this.handleWSTruncate);
|
||||
},
|
||||
|
||||
methods: {
|
||||
doSearch() {
|
||||
const s = this.getSearch()
|
||||
const s = this.getSearch();
|
||||
|
||||
if (!s) {
|
||||
mailbox.searching = false
|
||||
this.$router.push('/')
|
||||
return
|
||||
mailbox.searching = false;
|
||||
this.$router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
mailbox.searching = s
|
||||
mailbox.searching = s;
|
||||
|
||||
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
this.apiURI = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
|
||||
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
|
||||
this.apiURI += "&tz=" + encodeURIComponent(mailbox.timeZone);
|
||||
}
|
||||
this.loadMessages()
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
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
|
||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||
return
|
||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data };
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -92,52 +92,57 @@ export default {
|
||||
handleWSDelete(data) {
|
||||
let removed = 0;
|
||||
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
|
||||
this.mailbox.messages.splice(x, 1)
|
||||
removed++
|
||||
continue
|
||||
this.mailbox.messages.splice(x, 1);
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed || this.delayedRefresh) {
|
||||
// nothing changed on this screen, or a refresh is queued, don't refresh
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||
this.delayedRefresh = true
|
||||
this.delayedRefresh = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.delayedRefresh = false
|
||||
this.loadMessages()
|
||||
}, 500)
|
||||
this.delayedRefresh = false;
|
||||
this.loadMessages();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages deleted, go back to inbox
|
||||
this.$router.push('/')
|
||||
this.$router.push("/");
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col col-md-4k col-lg-5 col-xl-6">
|
||||
<SearchForm @loadMessages="loadMessages" />
|
||||
<SearchForm @load-messages="loadMessages" />
|
||||
</div>
|
||||
<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">
|
||||
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
|
||||
data-bs-target="#offcanvas" aria-controls="offcanvas">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
@ -145,20 +150,30 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
|
||||
aria-labelledby="offcanvasLabel">
|
||||
<div
|
||||
id="offcanvas"
|
||||
class="offcanvas-md offcanvas-start d-md-none"
|
||||
data-bs-scroll="true"
|
||||
tabindex="-1"
|
||||
aria-labelledby="offcanvasLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="offcanvas"
|
||||
data-bs-target="#offcanvas"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavSearch @load-messages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -166,20 +181,20 @@ export default {
|
||||
<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="flex-grow-1 overflow-y-auto me-n3 pe-3">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavSearch @load-messages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
<About />
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavSearch @loadMessages="loadMessages" modals />
|
||||
<AboutMailpit modals />
|
||||
<NavSearch modals @load-messages="loadMessages" />
|
||||
<About modals />
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
|
Reference in New Issue
Block a user