1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-08-13 20:04:49 +02:00

Merge branch 'feature/linting' into develop

This commit is contained in:
Ralph Slooten
2025-06-21 18:07:27 +12:00
52 changed files with 8811 additions and 3468 deletions

View File

View File

@@ -21,7 +21,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.23' go-version: 'stable'
cache-dependency-path: "**/*.sum" cache-dependency-path: "**/*.sum"
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v - run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
env: env:

View File

@@ -8,7 +8,7 @@ jobs:
test: test:
strategy: strategy:
matrix: matrix:
go-version: ['1.23'] go-version: [stable]
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
@@ -17,7 +17,7 @@ jobs:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
cache: false cache: false
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Run Go tests - name: Set up Go environment
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
@@ -26,19 +26,30 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: | restore-keys: |
${{ runner.os }}-go- ${{ runner.os }}-go-
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v - name: Test Go linting (gofmt)
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=. if: startsWith(matrix.os, 'ubuntu') == true
# https://olegk.dev/github-actions-and-go
run: gofmt -s -w . && git diff --exit-code
- name: Run Go tests
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
- name: Run Go benchmarking
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
# build the assets # build the assets
- name: Build web UI - name: Set up node environment
if: startsWith(matrix.os, 'ubuntu') == true if: startsWith(matrix.os, 'ubuntu') == true
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: 'npm' cache: 'npm'
- if: startsWith(matrix.os, 'ubuntu') == true - name: Install JavaScript dependencies
if: startsWith(matrix.os, 'ubuntu') == true
run: npm install run: npm install
- if: startsWith(matrix.os, 'ubuntu') == true - name: Run JavaScript linting
if: startsWith(matrix.os, 'ubuntu') == true
run: npm run lint
- name: Test JavaScript packaging
if: startsWith(matrix.os, 'ubuntu') == true
run: npm run package run: npm run package
# validate the swagger file # validate the swagger file

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Not within the scope of Prettier
**/*.yml
**/*.yaml
**/*.json
**/*.md
**/*.css
**/*.html
**/*.scss
composer.lock

39
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,39 @@
{
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"cSpell.words": [
"AUTHCRAMMD",
"AUTHLOGIN",
"AUTHPLAIN",
"bordercolor",
"CRAMMD",
"dateparse",
"EHLO",
"ESMTP",
"EXPN",
"gofmt",
"Healthz",
"HTTPIP",
"Inlines",
"jhillyerd",
"leporo",
"lithammer",
"livez",
"Mechs",
"navhtml",
"neostandard",
"popperjs",
"readyz",
"RSET",
"shortuuid",
"SMTPTLS",
"swaggerexpert",
"UITLS",
"VRFY",
"writef"
]
}

61
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Contributing guide
Thank you for your interest in contributing to Mailpit, your help is greatly appreciated! Please follow the guidelines below to ensure a smooth contribution process.
## Code of conduct
Please be respectful and considerate in all interactions. Mailpit is open source and free of charge, however is the result of thousands of hours of work.
## Reporting issues and feature requests
If you find a bug or have a feature request, please [open an issue](https://github.com/axllent/mailpit/issues) and provide as much detail as possible. Pleas do not report security issues here (see below).
## Reporting security issues
Please do not report security issues publicly in GitHub. Refer to [SECURITY document](https://github.com/axllent/mailpit/blob/develop/.github/SECURITY.md) for instructions and contact information.
## How to contribute (pull request)
1. **Fork the repository**
Click the "Fork" button at the top right of this repository to create your own copy.
2. **Clone your fork**
```bash
git clone https://github.com/your-username/mailpit.git
cd mailpit
```
3. **Create a branch**
Use a descriptive branch name:
```bash
git checkout -b feature/your-feature-name
```
4. **Make your changes**
Write clear, concise code and include comments where necessary.
5. **Test your changes**
Run all tests to ensure nothing is broken. This is a mandatory step as pull requests cannot be merged unless they pass the automated testing.
6. **Ensure your changes pass linting**
Ensure your changes pass the [code linting](https://mailpit.axllent.org/docs/development/code-linting/) requirements. This is a mandatory step as pull requests cannot be merged unless they pass the automated linting tests.
7. **Commit and push**
Write a clear commit message:
```bash
git add .
git commit -m "Describe your changes"
git push origin feature/your-feature-name
```
8. **Open a pull request**
Go to your fork on GitHub and open a pull request against the `develop` branch. Fill out the PR template and describe your changes.
---
Thank you for helping make this project awesome!

View File

@@ -1,44 +1,39 @@
import * as esbuild from 'esbuild' import * as esbuild from "esbuild";
import pluginVue from 'esbuild-plugin-vue-next' import pluginVue from "esbuild-plugin-vue-next";
import { sassPlugin } from 'esbuild-sass-plugin' import { sassPlugin } from "esbuild-sass-plugin";
const doWatch = process.env.WATCH == 'true' ? true : false; const doWatch = process.env.WATCH === "true";
const doMinify = process.env.MINIFY == 'true' ? true : false; const doMinify = process.env.MINIFY === "true";
const ctx = await esbuild.context( const ctx = await esbuild.context({
{ entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"],
entryPoints: [
"server/ui-src/app.js",
"server/ui-src/docs.js"
],
bundle: true, bundle: true,
minify: doMinify, minify: doMinify,
sourcemap: false, sourcemap: false,
define: { define: {
'__VUE_OPTIONS_API__': 'true', __VUE_OPTIONS_API__: "true",
'__VUE_PROD_DEVTOOLS__': 'false', __VUE_PROD_DEVTOOLS__: "false",
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false', __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
}, },
outdir: "server/ui/dist/", outdir: "server/ui/dist/",
plugins: [ plugins: [
pluginVue(), pluginVue(),
sassPlugin({ sassPlugin({
silenceDeprecations: ['import'], silenceDeprecations: ["import"],
quietDeps: true, quietDeps: true,
}) }),
], ],
loader: { loader: {
".svg": "file", ".svg": "file",
".woff": "file", ".woff": "file",
".woff2": "file", ".woff2": "file",
}, },
logLevel: "info" logLevel: "info",
} });
)
if (doWatch) { if (doWatch) {
await ctx.watch() await ctx.watch();
} else { } else {
await ctx.rebuild() await ctx.rebuild();
ctx.dispose() ctx.dispose();
} }

34
eslint.config.js Normal file
View 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",
},
},
];

View File

@@ -33,7 +33,7 @@ func LoadTagFilters() {
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'") logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
continue continue
} }
if t.Tags == nil || len(t.Tags) == 0 { if len(t.Tags) == 0 {
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array") logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
continue continue
} }

4338
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
{ {
"name": "mailpit", "name": "mailpit",
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "MINIFY=true node esbuild.config.mjs", "build": "MINIFY=true node esbuild.config.mjs",
"watch": "WATCH=true node esbuild.config.mjs", "watch": "WATCH=true node esbuild.config.mjs",
"package": "MINIFY=true node esbuild.config.mjs", "package": "MINIFY=true node esbuild.config.mjs",
"update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json" "update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json",
"lint": "eslint --max-warnings 0 && prettier -c .",
"lint-fix": "eslint --fix && prettier --write ."
}, },
"dependencies": { "dependencies": {
"axios": "^1.2.1", "axios": "^1.2.1",
@@ -33,6 +36,16 @@
"@vue/compiler-sfc": "^3.2.37", "@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"esbuild-plugin-vue-next": "^0.1.4", "esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^3.0.0" "esbuild-sass-plugin": "^3.0.0",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^10.2.0",
"neostandard": "^0.12.1",
"prettier": "^3.5.3"
},
"prettier":{
"tabWidth": 4,
"useTabs": true,
"printWidth": 120
} }
} }

View File

@@ -1,42 +1,41 @@
<script> <script>
import CommonMixins from './mixins/CommonMixins' import CommonMixins from "./mixins/CommonMixins";
import Favicon from './components/Favicon.vue' import Favicon from "./components/AppFavicon.vue";
import AppBadge from './components/AppBadge.vue' import AppBadge from "./components/AppBadge.vue";
import Notifications from './components/Notifications.vue' import Notifications from "./components/AppNotifications.vue";
import EditTags from './components/EditTags.vue' import EditTags from "./components/EditTags.vue";
import { mailbox } from "./stores/mailbox" import { mailbox } from "./stores/mailbox";
export default { export default {
mixins: [CommonMixins],
components: { components: {
Favicon, Favicon,
AppBadge, AppBadge,
Notifications, Notifications,
EditTags EditTags,
}, },
beforeMount() { mixins: [CommonMixins],
// load global config
this.get(this.resolve('/api/v1/webui'), false, function (response) {
mailbox.uiConfig = response.data
if (mailbox.uiConfig.Label) {
document.title = document.title + ' - ' + mailbox.uiConfig.Label
} else {
document.title = document.title + ' - ' + location.hostname
}
})
},
watch: { watch: {
$route(to, from) { $route(to, from) {
// hide mobile menu on URL change // hide mobile menu on URL change
this.hideNav() this.hideNav();
} },
}, },
beforeMount() {
// load global config
this.get(this.resolve("/api/v1/webui"), false, (response) => {
mailbox.uiConfig = response.data;
if (mailbox.uiConfig.Label) {
document.title = document.title + " - " + mailbox.uiConfig.Label;
} else {
document.title = document.title + " - " + location.hostname;
} }
});
},
};
</script> </script>
<template> <template>

View File

@@ -1,19 +1,19 @@
import App from './App.vue' import App from "./App.vue";
import router from './router' import router from "./router";
import { createApp } from 'vue' import { createApp } from "vue";
import mitt from 'mitt'; import mitt from "mitt";
import './assets/styles.scss' import "./assets/styles.scss";
import 'bootstrap-icons/font/bootstrap-icons.scss' import "bootstrap-icons/font/bootstrap-icons.scss";
import 'bootstrap' import "bootstrap";
import 'vue-css-donut-chart/src/styles/main.css' import "vue-css-donut-chart/src/styles/main.css";
const app = createApp(App) const app = createApp(App);
// Global event bus used to subscribe to websocket events // Global event bus used to subscribe to websocket events
// such as message deletes, updates & truncation. // such as message deletes, updates & truncation.
const eventBus = mitt() const eventBus = mitt();
app.provide('eventBus', eventBus) app.provide("eventBus", eventBus);
app.use(router) app.use(router);
app.mount('#app') app.mount("#app");

View File

@@ -1,13 +1,16 @@
<script> <script>
export default { export default {
props: { props: {
loading: Number, loading: {
type: Number,
default: 0,
}, },
} },
};
</script> </script>
<template> <template>
<div class="loader" v-if="loading > 0"> <div v-if="loading > 0" class="loader">
<div class="d-flex justify-content-center align-items-center h-100"> <div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-muted" role="status"> <div class="spinner-border text-muted" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>

View File

@@ -1,75 +1,83 @@
<script> <script>
import AjaxLoader from './AjaxLoader.vue' import AjaxLoader from "./AjaxLoader.vue";
import Settings from '../components/Settings.vue' import Settings from "./AppSettings.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
export default { export default {
mixins: [CommonMixins],
components: { components: {
AjaxLoader, AjaxLoader,
Settings, Settings,
}, },
mixins: [CommonMixins],
props: { props: {
modals: { modals: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
data() { data() {
return { return {
mailbox, mailbox,
} };
}, },
methods: { methods: {
loadInfo() { loadInfo() {
this.get(this.resolve('/api/v1/info'), false, (response) => { this.get(this.resolve("/api/v1/info"), false, (response) => {
mailbox.appInfo = response.data mailbox.appInfo = response.data;
this.modal('AppInfoModal').show() this.modal("AppInfoModal").show();
}) });
}, },
requestNotifications() { requestNotifications() {
// check if the browser supports notifications // check if the browser supports notifications
if (!("Notification" in window)) { if (!("Notification" in window)) {
alert("This browser does not support desktop notifications") alert("This browser does not support desktop notifications");
} }
// we need to ask the user for permission // we need to ask the user for permission
else if (Notification.permission !== "denied") { else if (Notification.permission !== "denied") {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
if (permission === "granted") { if (permission === "granted") {
mailbox.notificationsEnabled = true mailbox.notificationsEnabled = true;
} }
this.modal('EnableNotificationsModal').hide() this.modal("EnableNotificationsModal").hide();
}) });
} }
}, },
} },
} };
</script> </script>
<template> <template>
<template v-if="!modals"> <template v-if="!modals">
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit"> <div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
<button class="text-muted btn btn-sm" v-on:click="loadInfo()"> <button class="text-muted btn btn-sm" @click="loadInfo()">
<i class="bi bi-info-circle-fill me-1"></i> <i class="bi bi-info-circle-fill me-1"></i>
About About
</button> </button>
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal" <button
data-bs-target="#SettingsModal" title="Mailpit UI settings"> class="btn btn-sm btn-outline-secondary float-end"
data-bs-toggle="modal"
data-bs-target="#SettingsModal"
title="Mailpit UI settings"
>
<i class="bi bi-gear-fill"></i> <i class="bi bi-gear-fill"></i>
</button> </button>
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal" <button
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications" v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled"
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled"> class="btn btn-sm btn-outline-secondary float-end me-2"
data-bs-toggle="modal"
data-bs-target="#EnableNotificationsModal"
title="Enable browser notifications"
>
<i class="bi bi-bell"></i> <i class="bi bi-bell"></i>
</button> </button>
</div> </div>
@@ -77,12 +85,17 @@ export default {
<template v-else> <template v-else>
<!-- Modals --> <!-- Modals -->
<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" <div
aria-hidden="true"> id="AppInfoModal"
class="modal modal-xl fade"
tabindex="-1"
aria-labelledby="AppInfoModalLabel"
aria-hidden="true"
>
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content" v-if="mailbox.appInfo.RuntimeStats"> <div v-if="mailbox.appInfo.RuntimeStats" class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="AppInfoModalLabel"> <h5 id="AppInfoModalLabel" class="modal-title">
Mailpit Mailpit
<code>({{ mailbox.appInfo.Version }})</code> <code>({{ mailbox.appInfo.Version }})</code>
</h5> </h5>
@@ -92,19 +105,27 @@ export default {
<div class="row g-3"> <div class="row g-3">
<div class="col-xl-6"> <div class="col-xl-6">
<div v-if="mailbox.appInfo.LatestVersion != 'disabled'"> <div v-if="mailbox.appInfo.LatestVersion != 'disabled'">
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''"> <div v-if="mailbox.appInfo.LatestVersion == ''" class="row g-3">
<div class="col"> <div class="col">
<div class="alert alert-warning mb-3"> <div class="alert alert-warning mb-3">
There might be a newer version available. The check failed. There might be a newer version available. The check failed.
</div> </div>
</div> </div>
</div> </div>
<div class="row g-3" <div
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"> v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
class="row g-3"
>
<div class="col"> <div class="col">
<a class="btn btn-warning d-block mb-3" <a
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion"> class="btn btn-warning d-block mb-3"
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available. :href="
'https://github.com/axllent/mailpit/releases/tag/' +
mailbox.appInfo.LatestVersion
"
>
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
available.
</a> </a>
</div> </div>
</div> </div>
@@ -117,15 +138,21 @@ export default {
</RouterLink> </RouterLink>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit" <a
target="_blank"> class="btn btn-primary w-100"
href="https://github.com/axllent/mailpit"
target="_blank"
>
<i class="bi bi-github"></i> <i class="bi bi-github"></i>
Github Github
</a> </a>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<a class="btn btn-primary w-100" href="https://mailpit.axllent.org/docs/" <a
target="_blank"> class="btn btn-primary w-100"
href="https://mailpit.axllent.org/docs/"
target="_blank"
>
Documentation Documentation
</a> </a>
</div> </div>
@@ -133,7 +160,8 @@ export default {
<div class="card border-secondary text-center"> <div class="card border-secondary text-center">
<div class="card-header">Database size</div> <div class="card-header">Database size</div>
<div class="card-body text-muted"> <div class="card-body text-muted">
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }} <h5 class="card-title">
{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
</h5> </h5>
</div> </div>
</div> </div>
@@ -154,8 +182,7 @@ export default {
<div class="card border-secondary h-100"> <div class="card border-secondary h-100">
<div class="card-header h4"> <div class="card-header h4">
Runtime statistics Runtime statistics
<button class="btn btn-sm btn-outline-secondary float-end" <button class="btn btn-sm btn-outline-secondary float-end" @click="loadInfo()">
v-on:click="loadInfo()">
Refresh Refresh
</button> </button>
</div> </div>
@@ -163,46 +190,38 @@ export default {
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tbody> <tbody>
<tr> <tr>
<td> <td>Mailpit up since</td>
Mailpit up since
</td>
<td> <td>
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }} {{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>Messages deleted</td>
Messages deleted
</td>
<td> <td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }} {{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>SMTP messages accepted</td>
SMTP messages accepted
</td>
<td> <td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }} {{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
<small class="text-muted"> <small class="text-muted">
({{ ({{
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize) getFileSize(
mailbox.appInfo.RuntimeStats.SMTPAcceptedSize,
)
}}) }})
</small> </small>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>SMTP messages rejected</td>
SMTP messages rejected
</td>
<td> <td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }} {{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}
</td> </td>
</tr> </tr>
<tr v-if="mailbox.uiConfig.DuplicatesIgnored"> <tr v-if="mailbox.uiConfig.DuplicatesIgnored">
<td> <td>SMTP messages ignored</td>
SMTP messages ignored
</td>
<td> <td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }} {{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}
</td> </td>
@@ -210,12 +229,9 @@ export default {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
@@ -224,26 +240,30 @@ export default {
</div> </div>
</div> </div>
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" <div
aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true"> id="EnableNotificationsModal"
class="modal fade"
tabindex="-1"
aria-labelledby="EnableNotificationsModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5> <h5 id="EnableNotificationsModalLabel" class="modal-title">Enable browser notifications?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="h4">Get browser notifications when Mailpit receives new messages?</p> <p class="h4">Get browser notifications when Mailpit receives new messages?</p>
<p> <p>
Note that your browser will ask you for confirmation when you click Note that your browser will ask you for confirmation when you click
<code>enable notifications</code>, <code>enable notifications</code>, and that you must have Mailpit open in a browser tab to
and that you must have Mailpit open in a browser tab to be able to receive the be able to receive the notifications.
notifications.
</p> </p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" v-on:click="requestNotifications"> <button type="button" class="btn btn-success" @click="requestNotifications">
Enable notifications Enable notifications
</button> </button>
</div> </div>

View File

@@ -1,5 +1,5 @@
<script> <script>
import { mailbox } from '../stores/mailbox.js' import { mailbox } from "../stores/mailbox.js";
export default { export default {
data() { data() {
@@ -7,53 +7,51 @@ export default {
updating: false, updating: false,
needsUpdate: false, needsUpdate: false,
timeout: 500, timeout: 500,
} };
}, },
computed: { computed: {
mailboxUnread() { mailboxUnread() {
return mailbox.unread return mailbox.unread;
} },
}, },
watch: { watch: {
mailboxUnread: { mailboxUnread: {
handler() { handler() {
if (this.updating) { if (this.updating) {
this.needsUpdate = true this.needsUpdate = true;
return return;
} }
this.scheduleUpdate() this.scheduleUpdate();
},
immediate: true,
}, },
immediate: true
}
}, },
methods: { methods: {
scheduleUpdate() { scheduleUpdate() {
this.updating = true this.updating = true;
this.needsUpdate = false this.needsUpdate = false;
window.setTimeout(() => { window.setTimeout(() => {
this.updateAppBadge() this.updateAppBadge();
this.updating = false this.updating = false;
if (this.needsUpdate) { if (this.needsUpdate) {
this.scheduleUpdate() this.scheduleUpdate();
} }
}, this.timeout) }, this.timeout);
}, },
updateAppBadge() { updateAppBadge() {
if (!('setAppBadge' in navigator)) { if (!("setAppBadge" in navigator)) {
return return;
} }
navigator.setAppBadge(this.mailboxUnread) navigator.setAppBadge(this.mailboxUnread);
} },
} },
} };
</script> </script>
<template></template>

View File

@@ -0,0 +1,116 @@
<script>
import { mailbox } from "../stores/mailbox.js";
export default {
data() {
return {
favicon: false,
iconPath: false,
iconTextColor: "#ffffff",
iconBgColor: "#dd0000",
iconFontSize: 40,
iconProcessing: false,
iconTimeout: 500,
};
},
computed: {
count() {
let i = mailbox.unread;
if (i > 1000) {
i = Math.floor(i / 1000) + "k";
}
return i;
},
},
watch: {
count() {
if (!this.favicon || this.iconProcessing) {
return;
}
this.iconProcessing = true;
window.setTimeout(() => {
this.icoUpdate();
}, this.iconTimeout);
},
},
mounted() {
this.favicon = document.head.querySelector('link[rel="icon"]');
if (this.favicon) {
this.iconPath = this.favicon.href;
}
},
methods: {
async icoUpdate() {
if (!this.favicon) {
return;
}
if (!this.count) {
this.iconProcessing = false;
this.favicon.href = this.iconPath;
return;
}
let fontSize = this.iconFontSize;
// Draw badge text
let textPaddingX = 7;
const textPaddingY = 3;
const strlen = this.count.toString().length;
if (strlen > 2) {
// if text >= 3 characters then reduce size and padding
textPaddingX = 4;
fontSize = strlen > 3 ? 30 : 36;
}
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
// Draw base icon
const icon = new Image();
icon.src = this.iconPath;
await icon.decode();
ctx.drawImage(icon, 0, 0, 64, 64);
// Measure text
ctx.font = `${fontSize}px Arial, sans-serif`;
ctx.textAlign = "right";
ctx.textBaseline = "top";
const textMetrics = ctx.measureText(this.count);
// Draw badge
const paddingX = 7;
const paddingY = 4;
const cornerRadius = 8;
const width = textMetrics.width + paddingX * 2;
const height = fontSize + paddingY * 2;
const x = canvas.width - width;
const y = canvas.height - height - 1;
ctx.fillStyle = this.iconBgColor;
ctx.roundRect(x, y, width, height, cornerRadius);
ctx.fill();
ctx.fillStyle = this.iconTextColor;
ctx.fillText(this.count, canvas.width - textPaddingX, canvas.height - fontSize - textPaddingY);
this.iconProcessing = false;
this.favicon.href = canvas.toDataURL("image/png");
},
},
};
</script>

View File

@@ -0,0 +1,289 @@
<script>
import CommonMixins from "../mixins/CommonMixins";
import { Toast } from "bootstrap";
import { mailbox } from "../stores/mailbox";
import { pagination } from "../stores/pagination";
export default {
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() {
return {
pagination,
mailbox,
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
pauseNotifications: false, // prevent spamming
version: false,
clientErrors: [], // errors received via websocket
};
},
mounted() {
const d = document.getElementById("app");
if (d) {
this.version = d.dataset.version;
}
const proto = location.protocol === "https:" ? "wss" : "ws";
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`);
this.socketBreakReset();
this.connect();
mailbox.notificationsSupported =
window.isSecureContext && "Notification" in window && Notification.permission !== "denied";
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission === "granted";
this.errorNotificationCron();
},
methods: {
// websocket connect
connect() {
const ws = new WebSocket(this.socketURI);
ws.onmessage = (e) => {
let response;
try {
response = JSON.parse(e.data);
} catch (e) {
return;
}
// new messages
if (response.Type === "new" && response.Data) {
this.eventBus.emit("new", response.Data);
for (const i in response.Data.Tags) {
if (
mailbox.tags.findIndex((e) => {
return e.toLowerCase() === response.Data.Tags[i].toLowerCase();
}) < 0
) {
mailbox.tags.push(response.Data.Tags[i]);
mailbox.tags.sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
}
}
// send notifications
if (!this.pauseNotifications) {
this.pauseNotifications = true;
const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]";
this.browserNotify("New mail from: " + from, response.Data.Subject);
this.setMessageToast(response.Data);
// delay notifications by 2s
window.setTimeout(() => {
this.pauseNotifications = false;
}, 2000);
}
} else if (response.Type === "prune") {
// messages have been deleted, reload messages to adjust
window.scrollInPlace = true;
mailbox.refresh = true; // trigger refresh
window.setTimeout(() => {
mailbox.refresh = false;
}, 500);
this.eventBus.emit("prune");
} else if (response.Type === "stats" && response.Data) {
// refresh mailbox stats
mailbox.total = response.Data.Total;
mailbox.unread = response.Data.Unread;
// detect version updated, refresh is needed
if (this.version !== response.Data.Version) {
location.reload();
}
} else if (response.Type === "delete" && response.Data) {
// broadcast for components
this.eventBus.emit("delete", response.Data);
} else if (response.Type === "update" && response.Data) {
// broadcast for components
this.eventBus.emit("update", response.Data);
} else if (response.Type === "truncate") {
// broadcast for components
this.eventBus.emit("truncate");
} else if (response.Type === "error") {
// broadcast for components
this.addClientError(response.Data);
}
};
ws.onopen = () => {
mailbox.connected = true;
this.socketLastConnection = Date.now();
if (this.reconnectRefresh) {
this.reconnectRefresh = false;
mailbox.refresh = true; // trigger refresh
window.setTimeout(() => {
mailbox.refresh = false;
}, 500);
}
};
ws.onclose = (e) => {
if (this.socketLastConnection === 0) {
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
console.log("Unable to connect to websocket, disabling websocket support");
return;
}
if (mailbox.connected) {
// count disconnections
this.socketBreaks++;
}
// set disconnected state
mailbox.connected = false;
if (this.socketBreaks > 3) {
// give up after > 3 successful socket connections & disconnections within a 15 second window,
// something is not working right on their end, see issue #319
console.log("Unstable websocket connection, disabling websocket support");
return;
}
if (Date.now() - this.socketLastConnection > 5000) {
// only refresh mailbox if the last successful connection was broken for > 5 seconds
this.reconnectRefresh = true;
} else {
this.reconnectRefresh = false;
}
setTimeout(() => {
this.connect(); // reconnect
}, 1000);
};
ws.onerror = function () {
ws.close();
};
},
socketBreakReset() {
window.setTimeout(() => {
this.socketBreaks = 0;
this.socketBreakReset();
}, 15000);
},
browserNotify(title, message) {
if (!("Notification" in window)) {
return;
}
if (Notification.permission === "granted") {
const options = {
body: message,
icon: this.resolve("/notification.png"),
};
(() => new Notification(title, options))();
}
},
setMessageToast(m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return;
}
this.toastMessage = m;
const el = document.getElementById("messageToast");
if (el) {
el.addEventListener("hidden.bs.toast", () => {
this.toastMessage = false;
});
Toast.getOrCreateInstance(el).show();
}
},
closeToast() {
const el = document.getElementById("messageToast");
if (el) {
Toast.getOrCreateInstance(el).hide();
}
},
addClientError(d) {
d.expire = Date.now() + 5000; // expire after 5s
this.clientErrors.push(d);
},
errorNotificationCron() {
window.setTimeout(() => {
this.clientErrors.forEach((err, idx) => {
if (err.expire < Date.now()) {
this.clientErrors.splice(idx, 1);
}
});
this.errorNotificationCron();
}, 1000);
},
},
};
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div
v-for="(error, i) in clientErrors"
:key="'error_' + i"
class="toast show"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div class="toast-header">
<svg
class="bd-placeholder-img rounded me-2"
width="20"
height="20"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
preserveAspectRatio="xMidYMid slice"
focusable="false"
>
<rect width="100%" height="100%" :fill="error.Level === 'warning' ? '#ffc107' : '#dc3545'"></rect>
</svg>
<strong class="me-auto">{{ error.Type }}</strong>
<small class="text-body-secondary">{{ error.IP }}</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
{{ error.Message }}
</div>
</div>
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div v-if="toastMessage" class="toast-header">
<i class="bi bi-envelope-exclamation-fill me-2"></i>
<strong class="me-auto">
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div>
<RouterLink
:to="'/view/' + toastMessage.ID"
class="d-block text-truncate text-body-secondary"
@click="closeToast"
>
<template v-if="toastMessage.Subject !== ''">{{ toastMessage.Subject }}</template>
<template v-else> [ no subject ] </template>
</RouterLink>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,381 @@
<script>
import CommonMixins from "../mixins/CommonMixins";
import Tags from "bootstrap5-tags";
import timezones from "timezones-list";
import { mailbox } from "../stores/mailbox";
export default {
mixins: [CommonMixins],
data() {
return {
mailbox,
theme: localStorage.getItem("theme") ? localStorage.getItem("theme") : "auto",
timezones,
chaosConfig: false,
chaosUpdated: false,
};
},
watch: {
theme(v) {
if (v === "auto") {
localStorage.removeItem("theme");
} else {
localStorage.setItem("theme", v);
}
this.setTheme();
},
chaosConfig: {
handler() {
this.chaosUpdated = true;
},
deep: true,
},
"mailbox.skipConfirmations"(v) {
if (v) {
localStorage.setItem("skip-confirmations", "true");
} else {
localStorage.removeItem("skip-confirmations");
}
},
},
mounted() {
this.setTheme();
this.$nextTick(() => {
Tags.init("select.tz");
});
mailbox.skipConfirmations = !!localStorage.getItem("skip-confirmations");
},
methods: {
setTheme() {
if (this.theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.setAttribute("data-bs-theme", "dark");
} else {
document.documentElement.setAttribute("data-bs-theme", this.theme);
}
},
loadChaos() {
this.get(this.resolve("/api/v1/chaos"), null, (response) => {
this.chaosConfig = response.data;
this.$nextTick(() => {
this.chaosUpdated = false;
});
});
},
saveChaos() {
this.put(this.resolve("/api/v1/chaos"), this.chaosConfig, (response) => {
this.chaosConfig = response.data;
this.$nextTick(() => {
this.chaosUpdated = false;
});
});
},
},
};
</script>
<template>
<div
id="SettingsModal"
class="modal fade"
tabindex="-1"
aria-labelledby="SettingsModalLabel"
aria-hidden="true"
data-bs-keyboard="false"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 id="SettingsModalLabel" class="modal-title">Mailpit settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul v-if="mailbox.uiConfig.ChaosEnabled" id="myTab" class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button
id="ui-tab"
class="nav-link active"
data-bs-toggle="tab"
data-bs-target="#ui-tab-pane"
type="button"
role="tab"
aria-controls="ui-tab-pane"
aria-selected="true"
>
Web UI
</button>
</li>
<li class="nav-item" role="presentation">
<button
id="chaos-tab"
class="nav-link"
data-bs-toggle="tab"
data-bs-target="#chaos-tab-pane"
type="button"
role="tab"
aria-controls="chaos-tab-pane"
aria-selected="false"
@click="loadChaos"
>
Chaos
</button>
</li>
</ul>
<div class="tab-content">
<div
id="ui-tab-pane"
class="tab-pane fade show active"
role="tabpanel"
aria-labelledby="ui-tab"
tabindex="0"
>
<div class="my-3">
<label for="theme" class="form-label">Mailpit theme</label>
<select id="theme" v-model="theme" class="form-select">
<option value="auto">Auto (detect from browser)</option>
<option value="light">Light theme</option>
<option value="dark">Dark theme</option>
</select>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">Timezone (for date searches)</label>
<select
id="timezone"
v-model="mailbox.timeZone"
class="form-select tz"
data-allow-same="true"
>
<option disabled hidden value="">Select a timezone...</option>
<option v-for="t in timezones" :key="t" :value="t.tzCode">{{ t.label }}</option>
</select>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input
id="tagColors"
v-model="mailbox.showTagColors"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="tagColors">
Use auto-generated tag colors
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input
id="htmlCheck"
v-model="mailbox.showHTMLCheck"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="htmlCheck">
Show HTML check message tab
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input
id="linkCheck"
v-model="mailbox.showLinkCheck"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="linkCheck">
Show link check message tab
</label>
</div>
</div>
<div v-if="mailbox.uiConfig.SpamAssassin" class="mb-3">
<div class="form-check form-switch">
<input
id="spamCheck"
v-model="mailbox.showSpamCheck"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="spamCheck">
Show spam check message tab
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input
id="skip-confirmations"
v-model="mailbox.skipConfirmations"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="skip-confirmations">
Skip
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
<code>Delete all</code> &amp;
</template>
<code>Mark all read</code> confirmation dialogs
</label>
</div>
</div>
</div>
<div
v-if="mailbox.uiConfig.ChaosEnabled"
id="chaos-tab-pane"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="chaos-tab"
tabindex="0"
>
<p class="my-3">
<b>Chaos</b> allows you to set random SMTP failures and response codes at various stages
in a SMTP transaction to test application resilience (<a
href="https://mailpit.axllent.org/docs/integration/chaos/"
target="_blank"
>
see documentation </a
>).
</p>
<ul>
<li>
<code>Response code</code> is the SMTP error code returned by the server if this
error is triggered. Error codes must range between 400 and 599.
</li>
<li>
<code>Error probability</code> is the % chance that the error will occur per message
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
trigger. A probability of <code>50</code> will trigger on approximately 50% of
messages received.
</li>
</ul>
<template v-if="chaosConfig">
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
<div class="mb-4">
<label>Trigger: <code>Sender</code></label>
<div class="form-text">
Trigger an error response based on the sender (From / Sender).
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label"> Response code </label>
<input
v-model.number="chaosConfig.Sender.ErrorCode"
type="number"
class="form-control"
min="400"
max="599"
required
/>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Sender.Probability }}%)
</label>
<input
v-model.number="chaosConfig.Sender.Probability"
type="range"
class="form-range mt-1"
min="0"
max="100"
/>
</div>
</div>
</div>
<div class="mb-4">
<label>Trigger: <code>Recipient</code></label>
<div class="form-text">
Trigger an error response based on the recipients (To, Cc, Bcc).
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label"> Response code </label>
<input
v-model.number="chaosConfig.Recipient.ErrorCode"
type="number"
class="form-control"
min="400"
max="599"
required
/>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Recipient.Probability }}%)
</label>
<input
v-model.number="chaosConfig.Recipient.Probability"
type="range"
class="form-range mt-1"
min="0"
max="100"
/>
</div>
</div>
</div>
<div class="mb-4">
<label>Trigger: <code>Authentication</code></label>
<div class="form-text">
Trigger an authentication error response. Note that SMTP authentication must
be configured too.
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label"> Response code </label>
<input
v-model.number="chaosConfig.Authentication.ErrorCode"
type="number"
class="form-control"
min="400"
max="599"
required
/>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Authentication.Probability }}%)
</label>
<input
v-model.number="chaosConfig.Authentication.Probability"
type="range"
class="form-range mt-1"
min="0"
max="100"
/>
</div>
</div>
</div>
</div>
<div v-if="chaosUpdated" class="mb-3 text-center">
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
</div>
</template>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script> <script>
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
export default { export default {
mixins: [CommonMixins], mixins: [CommonMixins],
@@ -9,74 +9,83 @@ export default {
return { return {
mailbox, mailbox,
editableTags: [], editableTags: [],
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/), validTagRe: /^([a-zA-Z0-9\- ._]){1,}$/,
tagToDelete: false, tagToDelete: false,
} };
}, },
watch: { watch: {
'mailbox.tags': { "mailbox.tags": {
handler(tags) { handler(tags) {
this.editableTags = [] this.editableTags = [];
tags.forEach((t) => { tags.forEach((t) => {
this.editableTags.push({ before: t, after: t }) this.editableTags.push({ before: t, after: t });
}) });
},
deep: true,
}, },
deep: true
}
}, },
methods: { methods: {
validTag(t) { validTag(t) {
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) { if (!t.after.match(/^([a-zA-Z0-9\- _.]){1,}$/)) {
return false return false;
} }
const lower = t.after.toLowerCase() const lower = t.after.toLowerCase();
for (let x = 0; x < this.editableTags.length; x++) { for (let x = 0; x < this.editableTags.length; x++) {
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) { if (this.editableTags[x].before !== t.before && lower === this.editableTags[x].before.toLowerCase()) {
return false return false;
} }
} }
return true return true;
}, },
renameTag(t) { renameTag(t) {
if (!this.validTag(t) || t.before == t.after) { if (!this.validTag(t) || t.before === t.after) {
return return;
} }
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => { this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
// the API triggers a reload via websockets // the API triggers a reload via websockets
}) });
}, },
deleteTag() { deleteTag() {
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => { this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
// the API triggers a reload via websockets // the API triggers a reload via websockets
this.tagToDelete = false this.tagToDelete = false;
}) });
}, },
resetTagEdit(t) { resetTagEdit(t) {
for (let x = 0; x < this.editableTags.length; x++) { for (let x = 0; x < this.editableTags.length; x++) {
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) { if (
this.editableTags[x].after = this.editableTags[x].before this.editableTags[x].before !== t.before &&
} this.editableTags[x].before !== this.editableTags[x].after
} ) {
} this.editableTags[x].after = this.editableTags[x].before;
} }
} }
},
},
};
</script> </script>
<template> <template>
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true" <div
data-bs-keyboard="false"> id="EditTagsModal"
class="modal fade"
tabindex="-1"
aria-labelledby="EditTagsModalLabel"
aria-hidden="true"
data-bs-keyboard="false"
>
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5> <h5 id="EditTagsModalLabel" class="modal-title">Edit tags</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -84,29 +93,34 @@ export default {
Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag
itself, and not any messages which had the tag. itself, and not any messages which had the tag.
</p> </p>
<div class="mb-3" v-for="t in editableTags"> <div v-for="(t, i) in editableTags" :key="'tag_' + i" class="mb-3">
<div class="input-group has-validation"> <div class="input-group has-validation">
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''" <input
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required v-model.trim="t.after"
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before" type="text"
@focus="resetTagEdit(t)"> class="form-control"
<button v-if="t.before != t.after" class="btn btn-success" :class="!validTag(t) ? 'is-invalid' : ''"
@click="renameTag(t)">Save</button> aria-describedby="inputGroupPrepend"
required
@keydown.enter="renameTag(t)"
@keydown.esc="t.after = t.before"
@focus="resetTagEdit(t)"
/>
<button v-if="t.before != t.after" class="btn btn-success" @click="renameTag(t)">
Save
</button>
<template v-else> <template v-else>
<button class="btn btn-outline-danger" <button
class="btn btn-outline-danger"
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''" :class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false"> @click="!tagToDelete ? (tagToDelete = t) : deleteTag()"
<template v-if="tagToDelete == t"> @blur="tagToDelete = false"
Confirm? >
</template> <template v-if="tagToDelete == t"> Confirm? </template>
<template v-else> <template v-else> Delete </template>
Delete
</template>
</button> </button>
</template> </template>
<div class="invalid-feedback"> <div class="invalid-feedback">Invalid tag name</div>
Invalid tag name
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,122 +0,0 @@
<script>
import { mailbox } from '../stores/mailbox.js'
export default {
data() {
return {
favicon: false,
iconPath: false,
iconTextColor: '#ffffff',
iconBgColor: '#dd0000',
iconFontSize: 40,
iconProcessing: false,
iconTimeout: 500,
}
},
mounted() {
this.favicon = document.head.querySelector('link[rel="icon"]')
if (this.favicon) {
this.iconPath = this.favicon.href
}
},
computed: {
count() {
let i = mailbox.unread
if (i > 1000) {
i = Math.floor(i / 1000) + 'k'
}
return i
}
},
watch: {
count() {
if (!this.favicon || this.iconProcessing) {
return
}
this.iconProcessing = true
window.setTimeout(() => {
this.icoUpdate()
}, this.iconTimeout)
},
},
methods: {
async icoUpdate() {
if (!this.favicon) {
return
}
if (!this.count) {
this.iconProcessing = false
this.favicon.href = this.iconPath
return
}
let fontSize = this.iconFontSize
// Draw badge text
let textPaddingX = 7
let textPaddingY = 3
let strlen = this.count.toString().length
if (strlen > 2) {
// if text >= 3 characters then reduce size and padding
textPaddingX = 4
fontSize = strlen > 3 ? 30 : 36
}
let canvas = document.createElement('canvas')
canvas.width = 64
canvas.height = 64
let ctx = canvas.getContext('2d')
// Draw base icon
let icon = new Image()
icon.src = this.iconPath
await icon.decode()
ctx.drawImage(icon, 0, 0, 64, 64)
// Measure text
ctx.font = `${fontSize}px Arial, sans-serif`
ctx.textAlign = 'right'
ctx.textBaseline = 'top'
let textMetrics = ctx.measureText(this.count)
// Draw badge
let paddingX = 7
let paddingY = 4
let cornerRadius = 8
let width = textMetrics.width + paddingX * 2
let height = fontSize + paddingY * 2
let x = canvas.width - width
let y = canvas.height - height - 1
ctx.fillStyle = this.iconBgColor
ctx.roundRect(x, y, width, height, cornerRadius)
ctx.fill()
ctx.fillStyle = this.iconTextColor
ctx.fillText(
this.count,
canvas.width - textPaddingX,
canvas.height - fontSize - textPaddingY
)
this.iconProcessing = false
this.favicon.href = canvas.toDataURL("image/png")
}
}
}
</script>
<template></template>

View File

@@ -1,135 +1,142 @@
<script> <script>
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import dayjs from 'dayjs' import dayjs from "dayjs";
import { pagination } from "../stores/pagination"; import { pagination } from "../stores/pagination";
export default { export default {
mixins: [ mixins: [CommonMixins],
CommonMixins
],
props: { props: {
loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins // use different name to `loading` as that is already in use in CommonMixins
loadingMessages: {
type: Number,
default: 0,
},
}, },
data() { data() {
return { return {
mailbox, mailbox,
pagination, pagination,
} };
}, },
created() { created() {
const relativeTime = require('dayjs/plugin/relativeTime') const relativeTime = require("dayjs/plugin/relativeTime");
dayjs.extend(relativeTime) dayjs.extend(relativeTime);
}, },
mounted() { mounted() {
this.refreshUI() this.refreshUI();
}, },
methods: { methods: {
refreshUI() { refreshUI() {
window.setTimeout(() => { window.setTimeout(() => {
this.$forceUpdate() this.$forceUpdate();
this.refreshUI() this.refreshUI();
}, 30000) }, 30000);
}, },
getRelativeCreated(message) { getRelativeCreated(message) {
const d = new Date(message.Created) const d = new Date(message.Created);
return dayjs(d).fromNow() return dayjs(d).fromNow();
}, },
getPrimaryEmailTo(message) { getPrimaryEmailTo(message) {
for (let i in message.To) { if (message.To && message.To.length > 0) {
return message.To[i].Address return message.To[0].Address;
} }
return '[ Undisclosed recipients ]' return "[ Undisclosed recipients ]";
}, },
isSelected(id) { isSelected(id) {
return mailbox.selected.indexOf(id) != -1 return mailbox.selected.indexOf(id) !== -1;
}, },
toggleSelected(e, id) { toggleSelected(e, id) {
e.preventDefault() e.preventDefault();
if (this.isSelected(id)) { if (this.isSelected(id)) {
mailbox.selected = mailbox.selected.filter(function (ele) { mailbox.selected = mailbox.selected.filter((ele) => {
return ele != id return ele !== id;
}) });
} else { } else {
mailbox.selected.push(id) mailbox.selected.push(id);
} }
}, },
selectRange(e, id) { selectRange(e, id) {
e.preventDefault() e.preventDefault();
let selecting = false let selecting = false;
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1] const lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1];
if (lastSelected == id) { if (lastSelected === id) {
mailbox.selected = mailbox.selected.filter(function (ele) { mailbox.selected = mailbox.selected.filter((ele) => {
return ele != id return ele !== id;
}) });
return return;
} }
if (lastSelected === false) { if (lastSelected === false) {
mailbox.selected.push(id) mailbox.selected.push(id);
return return;
} }
for (let d of mailbox.messages) { for (const d of mailbox.messages) {
if (selecting) { if (selecting) {
if (!this.isSelected(d.ID)) { if (!this.isSelected(d.ID)) {
mailbox.selected.push(d.ID) mailbox.selected.push(d.ID);
} }
if (d.ID == lastSelected || d.ID == id) { if (d.ID === lastSelected || d.ID === id) {
// reached backwards select // reached backwards select
break break;
} }
} else if (d.ID == id || d.ID == lastSelected) { } else if (d.ID === id || d.ID === lastSelected) {
if (!this.isSelected(d.ID)) { if (!this.isSelected(d.ID)) {
mailbox.selected.push(d.ID) mailbox.selected.push(d.ID);
} }
selecting = true selecting = true;
} }
} }
}, },
toTagUrl(t) { toTagUrl(t) {
if (t.match(/ /)) { if (t.match(/ /)) {
t = `"${t}"` t = `"${t}"`;
} }
const p = { const p = {
q: 'tag:' + t q: "tag:" + t,
};
if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString();
} }
if (pagination.limit != pagination.defaultLimit) { const params = new URLSearchParams(p);
p.limit = pagination.limit.toString() return "/search?" + params.toString();
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
}, },
} },
} };
</script> </script>
<template> <template>
<template v-if="mailbox.messages && mailbox.messages.length"> <template v-if="mailbox.messages && mailbox.messages.length">
<div class="list-group my-2"> <div class="list-group my-2">
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID" <RouterLink
v-for="message in mailbox.messages"
:id="message.ID" :id="message.ID"
:key="'message_' + message.ID"
:to="'/view/' + message.ID"
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0" class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''" :class="[message.Read ? 'read' : '', isSelected(message.ID) ? ' selected' : '']"
@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)" @click.meta="toggleSelected($event, message.ID)"
@click.shift="selectRange($event, message.ID)"> @click.ctrl="toggleSelected($event, message.ID)"
@click.shift="selectRange($event, message.ID)"
>
<div class="col-lg-3"> <div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small"> <div class="d-lg-none float-end text-muted text-nowrap small">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i> <i v-if="message.Attachments" class="bi bi-paperclip h6 me-1"></i>
{{ getRelativeCreated(message) }} {{ getRelativeCreated(message) }}
</div> </div>
<div v-if="message.From" class="overflow-x-hidden"> <div v-if="message.From" class="overflow-x-hidden">
@@ -142,30 +149,37 @@ export default {
<div class="overflow-x-hidden"> <div class="overflow-x-hidden">
<div class="text-truncate text-muted small privacy"> <div class="text-truncate text-muted small privacy">
To: {{ getPrimaryEmailTo(message) }} To: {{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1"> <span v-if="message.To && message.To.length > 1"> [+{{ message.To.length - 1 }}] </span>
[+{{ message.To.length - 1 }}]
</span>
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0"> <div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
<div class="subject text-truncate text-spaces-nowrap"> <div class="subject text-truncate text-spaces-nowrap">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b> <b>{{ message.Subject !== "" ? message.Subject : "[ no subject ]" }}</b>
</div> </div>
<div v-if="message.Snippet != ''" class="small text-muted text-truncate"> <div v-if="message.Snippet !== ''" class="small text-muted text-truncate">
{{ message.Snippet }} {{ message.Snippet }}
</div> </div>
<div v-if="message.Tags.length"> <div v-if="message.Tags.length">
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)" <RouterLink
v-on:click="pagination.start = 0" v-for="t in message.Tags"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }" :key="t"
:title="'Filter messages tagged with ' + t"> class="badge me-1"
:to="toTagUrl(t)"
:style="
mailbox.showTagColors
? { backgroundColor: colorHash(t) }
: { backgroundColor: '#6c757d' }
"
:title="'Filter messages tagged with ' + t"
@click="pagination.start = 0"
>
{{ t }} {{ t }}
</RouterLink> </RouterLink>
</div> </div>
</div> </div>
<div class="d-none d-lg-block col-1 small text-end text-muted"> <div class="d-none d-lg-block col-1 small text-end text-muted">
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i> <i v-if="message.Attachments" class="bi bi-paperclip float-start h6"></i>
{{ getFileSize(message.Size) }} {{ getFileSize(message.Size) }}
</div> </div>
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted"> <div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
@@ -176,10 +190,10 @@ export default {
</template> </template>
<template v-else> <template v-else>
<p class="text-center mt-5"> <p class="text-center mt-5">
<span v-if="loadingMessages > 0" class="text-muted"> <span v-if="loadingMessages > 0" class="text-muted"> Loading messages... </span>
Loading messages... <template v-else-if="getSearch()"
</span> >No results for <code>{{ getSearch() }}</code></template
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template> >
<template v-else>No messages in your mailbox</template> <template v-else>No messages in your mailbox</template>
</p> </p>
</template> </template>

View File

@@ -1,156 +1,193 @@
<script> <script>
import NavSelected from '../components/NavSelected.vue' import NavSelected from "../components/NavSelected.vue";
import AjaxLoader from "./AjaxLoader.vue" import AjaxLoader from "./AjaxLoader.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
import { pagination } from '../stores/pagination' import { pagination } from "../stores/pagination";
export default { export default {
mixins: [CommonMixins],
components: { components: {
NavSelected, NavSelected,
AjaxLoader, AjaxLoader,
}, },
mixins: [CommonMixins],
props: { props: {
modals: { modals: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
emits: ['loadMessages'], emits: ["loadMessages"],
data() { data() {
return { return {
mailbox, mailbox,
pagination, pagination,
} };
}, },
methods: { methods: {
reloadInbox() { reloadInbox() {
const paginationParams = this.getPaginationParams() const paginationParams = this.getPaginationParams();
const reload = paginationParams?.start ? false : true const reload = !paginationParams?.start;
this.$router.push('/') this.$router.push("/");
if (reload) { if (reload) {
// already on first page, reload messages // already on first page, reload messages
this.loadMessages() this.loadMessages();
} }
}, },
loadMessages() { loadMessages() {
this.hideNav() // hide mobile menu this.hideNav(); // hide mobile menu
this.$emit('loadMessages') this.$emit("loadMessages");
}, },
markAllRead() { markAllRead() {
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => { this.put(this.resolve(`/api/v1/messages`), { read: true }, (response) => {
window.scrollInPlace = true window.scrollInPlace = true;
this.loadMessages() this.loadMessages();
}) });
}, },
deleteAllMessages() { deleteAllMessages() {
this.delete(this.resolve(`/api/v1/messages`), false, (response) => { this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
pagination.start = 0 pagination.start = 0;
this.loadMessages() this.loadMessages();
}) });
} },
} },
} };
</script> </script>
<template> <template>
<template v-if="!modals"> <template v-if="!modals">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label"> <div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
<div class="text-truncate fw-normal" style="line-height: 1rem"> <div class="text-truncate fw-normal" style="line-height: 1rem">
{{ mailbox.uiConfig.Label }} {{ mailbox.uiConfig.Label }}
</div> </div>
</div> </div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''"> <div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="reloadInbox" class="list-group-item list-group-item-action active"> <button class="list-group-item list-group-item-action active" @click="reloadInbox">
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i> <i v-if="mailbox.connected" class="bi bi-envelope-fill me-1"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i> <i v-else class="bi bi-arrow-clockwise me-1"></i>
<span class="ms-1">Inbox</span> <span class="ms-1">Inbox</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages" <span
v-if="mailbox.unread"> v-if="mailbox.unread"
class="badge rounded-pill ms-1 float-end text-bg-secondary"
title="Unread messages"
>
{{ formatNumber(mailbox.unread) }} {{ formatNumber(mailbox.unread) }}
</span> </span>
</button> </button>
<template v-if="!mailbox.selected.length"> <template v-if="!mailbox.selected.length">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" <button
:disabled="!mailbox.messages_unread" @click="markAllRead"> v-if="mailbox.skipConfirmations"
class="list-group-item list-group-item-action"
:disabled="!mailbox.messages_unread"
@click="markAllRead"
>
<i class="bi bi-eye-fill me-1"></i> <i class="bi bi-eye-fill me-1"></i>
Mark all read Mark all read
</button> </button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" <button
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread"> v-else
class="list-group-item list-group-item-action"
data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal"
:disabled="!mailbox.messages_unread"
>
<i class="bi bi-eye-fill me-1"></i> <i class="bi bi-eye-fill me-1"></i>
Mark all read Mark all read
</button> </button>
<!-- checking if MessageRelay is defined prevents UI flicker while loading --> <!-- checking if MessageRelay is defined prevents UI flicker while loading -->
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton"> <template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" <button
:disabled="!mailbox.total" @click="deleteAllMessages"> v-if="mailbox.skipConfirmations"
class="list-group-item list-group-item-action"
:disabled="!mailbox.total"
@click="deleteAllMessages"
>
<i class="bi bi-trash-fill me-1 text-danger"></i> <i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all Delete all
</button> </button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" <button
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total"> v-else
class="list-group-item list-group-item-action"
data-bs-toggle="modal"
data-bs-target="#DeleteAllModal"
:disabled="!mailbox.total"
>
<i class="bi bi-trash-fill me-1 text-danger"></i> <i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all Delete all
</button> </button>
</template> </template>
</template> </template>
<NavSelected @loadMessages="loadMessages" /> <NavSelected @load-messages="loadMessages" />
</div> </div>
</template> </template>
<template v-else> <template v-else>
<!-- Modals --> <!-- Modals -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" <div
aria-hidden="true"> id="MarkAllReadModal"
class="modal fade"
tabindex="-1"
aria-labelledby="MarkAllReadModalLabel"
aria-hidden="true"
>
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5> <h5 id="MarkAllReadModalLabel" class="modal-title">Mark all messages as read?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
This will mark {{ formatNumber(mailbox.unread) }} This will mark {{ formatNumber(mailbox.unread) }} message<span v-if="mailbox.unread > 1"
message<span v-if="mailbox.unread > 1">s</span> as read. >s</span
>
as read.
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" <button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
v-on:click="markAllRead">Confirm</button> Confirm
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" <div
aria-hidden="true"> id="DeleteAllModal"
class="modal fade"
tabindex="-1"
aria-labelledby="DeleteAllModalLabel"
aria-hidden="true"
>
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5> <h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
This will permanently delete {{ formatNumber(mailbox.total) }} This will permanently delete {{ formatNumber(mailbox.total) }} message<span
message<span v-if="mailbox.total > 1">s</span>. v-if="mailbox.total > 1"
>s</span
>.
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" <button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
v-on:click="deleteAllMessages">Delete</button> Delete
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,120 @@
<script>
import CommonMixins from "../mixins/CommonMixins";
import { mailbox } from "../stores/mailbox";
import { limitOptions, pagination } from "../stores/pagination";
export default {
mixins: [CommonMixins],
props: {
total: {
type: Number,
default: 0,
},
},
data() {
return {
pagination,
mailbox,
limitOptions,
};
},
computed: {
canPrev() {
return pagination.start > 0;
},
canNext() {
return this.total > pagination.start + mailbox.messages.length;
},
// returns the number of next X messages
nextMessages() {
let t = pagination.start + parseInt(pagination.limit, 10);
if (t > this.total) {
t = this.total;
}
return t;
},
},
methods: {
changeLimit() {
pagination.start = 0;
this.updateQueryParams();
},
viewNext() {
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10);
this.updateQueryParams();
},
viewPrev() {
let s = pagination.start - pagination.limit;
if (s < 0) {
s = 0;
}
pagination.start = s;
this.updateQueryParams();
},
updateQueryParams() {
const path = this.$route.path;
const p = {
...this.$route.query,
};
if (pagination.start > 0) {
p.start = pagination.start.toString();
} else {
delete p.start;
}
if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString();
} else {
delete p.limit;
}
const params = new URLSearchParams(p);
this.$router.push(path + "?" + params.toString());
},
},
};
</script>
<template>
<select
v-model="pagination.limit"
class="form-select form-select-sm d-inline w-auto me-2"
:disabled="total == 0"
@change="changeLimit"
>
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
</select>
<small>
<template v-if="total > 0">
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
<small>of</small>
{{ formatNumber(total) }}
</template>
<span v-else class="text-muted">0 of 0</span>
</small>
<button
class="btn btn-outline-light ms-2 me-1"
:disabled="!canPrev"
:title="'View previous ' + pagination.limit + ' messages'"
@click="viewPrev"
>
<i class="bi bi-caret-left-fill"></i>
</button>
<button
class="btn btn-outline-light"
:disabled="!canNext"
:title="'View next ' + pagination.limit + ' messages'"
@click="viewNext"
>
<i class="bi bi-caret-right-fill"></i>
</button>
</template>

View File

@@ -1,79 +1,79 @@
<script> <script>
import NavSelected from '../components/NavSelected.vue' import NavSelected from "../components/NavSelected.vue";
import AjaxLoader from './AjaxLoader.vue' import AjaxLoader from "./AjaxLoader.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
import { pagination } from '../stores/pagination' import { pagination } from "../stores/pagination";
export default { export default {
mixins: [CommonMixins],
components: { components: {
NavSelected, NavSelected,
AjaxLoader, AjaxLoader,
}, },
mixins: [CommonMixins],
props: { props: {
modals: { modals: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
emits: ['loadMessages'], emits: ["loadMessages"],
data() { data() {
return { return {
mailbox, mailbox,
pagination, pagination,
} };
}, },
methods: { methods: {
loadMessages() { loadMessages() {
this.hideNav() // hide mobile menu this.hideNav(); // hide mobile menu
this.$emit('loadMessages') this.$emit("loadMessages");
}, },
deleteAllMessages() { deleteAllMessages() {
const s = this.getSearch() const s = this.getSearch();
if (!s) { if (!s) {
return return;
} }
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s) let uri = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) { if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
uri += '&tz=' + encodeURIComponent(mailbox.timeZone) uri += "&tz=" + encodeURIComponent(mailbox.timeZone);
} }
this.delete(uri, false, () => { this.delete(uri, false, () => {
this.$router.push('/') this.$router.push("/");
}) });
}, },
markAllRead() { markAllRead() {
const s = this.getSearch() const s = this.getSearch();
if (!s) { if (!s) {
return return;
} }
let uri = this.resolve(`/api/v1/messages`) let uri = this.resolve(`/api/v1/messages`);
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) { if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
uri += '?tz=' + encodeURIComponent(mailbox.timeZone) uri += "?tz=" + encodeURIComponent(mailbox.timeZone);
} }
this.put(uri, { 'read': true, "search": s }, () => { this.put(uri, { read: true, search: s }, () => {
window.scrollInPlace = true window.scrollInPlace = true;
this.loadMessages() this.loadMessages();
}) });
}, },
} },
} };
</script> </script>
<template> <template>
<template v-if="!modals"> <template v-if="!modals">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label"> <div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
<div class="text-truncate fw-normal" style="line-height: 1rem"> <div class="text-truncate fw-normal" style="line-height: 1rem">
{{ mailbox.uiConfig.Label }} {{ mailbox.uiConfig.Label }}
</div> </div>
@@ -83,83 +83,121 @@ export default {
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0"> <RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
<i class="bi bi-arrow-return-left me-1"></i> <i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Inbox</span> <span class="ms-1">Inbox</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages" <span
v-if="mailbox.unread"> v-if="mailbox.unread"
class="badge rounded-pill ms-1 float-end text-bg-secondary"
title="Unread messages"
>
{{ formatNumber(mailbox.unread) }} {{ formatNumber(mailbox.unread) }}
</span> </span>
</RouterLink> </RouterLink>
<template v-if="!mailbox.selected.length"> <template v-if="!mailbox.selected.length">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" <button
:disabled="!mailbox.messages_unread" @click="markAllRead"> v-if="mailbox.skipConfirmations"
class="list-group-item list-group-item-action"
:disabled="!mailbox.messages_unread"
@click="markAllRead"
>
<i class="bi bi-eye-fill me-1"></i> <i class="bi bi-eye-fill me-1"></i>
Mark all read Mark all read
</button> </button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" <button
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread"> v-else
class="list-group-item list-group-item-action"
data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal"
:disabled="!mailbox.messages_unread"
>
<i class="bi bi-eye-fill me-1"></i> <i class="bi bi-eye-fill me-1"></i>
Mark all read Mark all read
</button> </button>
<!-- checking if MessageRelay is defined prevents UI flicker while loading --> <!-- checking if MessageRelay is defined prevents UI flicker while loading -->
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton"> <template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" <button
@click="deleteAllMessages" :disabled="!mailbox.count"> v-if="mailbox.skipConfirmations"
class="list-group-item list-group-item-action"
:disabled="!mailbox.count"
@click="deleteAllMessages"
>
<i class="bi bi-trash-fill me-1 text-danger"></i> <i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all Delete all
</button> </button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" <button
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count"> v-else
class="list-group-item list-group-item-action"
data-bs-toggle="modal"
data-bs-target="#DeleteAllModal"
:disabled="!mailbox.count"
>
<i class="bi bi-trash-fill me-1 text-danger"></i> <i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all Delete all
</button> </button>
</template> </template>
</template> </template>
<NavSelected @loadMessages="loadMessages" /> <NavSelected @load-messages="loadMessages" />
</div> </div>
</template> </template>
<template v-else> <template v-else>
<!-- Modals --> <!-- Modals -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" <div
aria-hidden="true"> id="MarkAllReadModal"
class="modal fade"
tabindex="-1"
aria-labelledby="MarkAllReadModalLabel"
aria-hidden="true"
>
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all search results as read?</h5> <h5 id="MarkAllReadModalLabel" class="modal-title">Mark all search results as read?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
This will mark {{ formatNumber(mailbox.messages_unread) }} This will mark {{ formatNumber(mailbox.messages_unread) }} message<span
message<span v-if="mailbox.messages_unread > 1">s</span> v-if="mailbox.messages_unread > 1"
>s</span
>
matching <code>{{ getSearch() }}</code> matching <code>{{ getSearch() }}</code>
as read. as read.
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" <button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
v-on:click="markAllRead">Confirm</button> Confirm
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" <div
aria-hidden="true"> id="DeleteAllModal"
class="modal fade"
tabindex="-1"
aria-labelledby="DeleteAllModalLabel"
aria-hidden="true"
>
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages matching search?</h5> <h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages matching search?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
This will permanently delete {{ formatNumber(mailbox.count) }} This will permanently delete {{ formatNumber(mailbox.count) }} message<span
message<span v-if="mailbox.count > 1">s</span> matching v-if="mailbox.count > 1"
>s</span
>
matching
<code>{{ getSearch() }}</code> <code>{{ getSearch() }}</code>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" <button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
v-on:click="deleteAllMessages">Delete</button> Delete
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,114 +1,120 @@
<script> <script>
import AjaxLoader from './AjaxLoader.vue' import AjaxLoader from "./AjaxLoader.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
export default { export default {
mixins: [CommonMixins],
components: { components: {
AjaxLoader, AjaxLoader,
}, },
emits: ['loadMessages'], mixins: [CommonMixins],
emits: ["loadMessages"],
data() { data() {
return { return {
mailbox, mailbox,
} };
}, },
methods: { methods: {
loadMessages() { loadMessages() {
this.$emit('loadMessages') this.$emit("loadMessages");
}, },
// mark selected messages as read // mark selected messages as read
markSelectedRead() { markSelectedRead() {
if (!mailbox.selected.length) { if (!mailbox.selected.length) {
return false return false;
} }
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => { this.put(this.resolve(`/api/v1/messages`), { Read: true, IDs: mailbox.selected }, (response) => {
window.scrollInPlace = true window.scrollInPlace = true;
this.loadMessages() this.loadMessages();
}) });
}, },
isSelected(id) { isSelected(id) {
return mailbox.selected.indexOf(id) != -1 return mailbox.selected.indexOf(id) !== -1;
}, },
// mark selected messages as unread // mark selected messages as unread
markSelectedUnread() { markSelectedUnread() {
if (!mailbox.selected.length) { if (!mailbox.selected.length) {
return false return false;
} }
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => { this.put(this.resolve(`/api/v1/messages`), { Read: false, IDs: mailbox.selected }, (response) => {
window.scrollInPlace = true window.scrollInPlace = true;
this.loadMessages() this.loadMessages();
}) });
}, },
// universal handler to delete current or selected messages // universal handler to delete current or selected messages
deleteMessages() { deleteMessages() {
let ids = [] let ids = [];
ids = JSON.parse(JSON.stringify(mailbox.selected)) ids = JSON.parse(JSON.stringify(mailbox.selected));
if (!ids.length) { if (!ids.length) {
return false return false;
} }
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => { this.delete(this.resolve(`/api/v1/messages`), { IDs: ids }, (response) => {
window.scrollInPlace = true window.scrollInPlace = true;
this.loadMessages() this.loadMessages();
}) });
}, },
// test if any selected emails are unread // test if any selected emails are unread
selectedHasUnread() { selectedHasUnread() {
if (!mailbox.selected.length) { if (!mailbox.selected.length) {
return false return false;
} }
for (let i in mailbox.messages) { for (const i in mailbox.messages) {
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) { if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
return true return true;
} }
} }
return false return false;
}, },
// test of any selected emails are read // test of any selected emails are read
selectedHasRead() { selectedHasRead() {
if (!mailbox.selected.length) { if (!mailbox.selected.length) {
return false return false;
} }
for (let i in mailbox.messages) { for (const i in mailbox.messages) {
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) { if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
return true return true;
} }
} }
return false return false;
}, },
} },
} };
</script> </script>
<template> <template>
<template v-if="mailbox.selected.length"> <template v-if="mailbox.selected.length">
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()" <button
v-on:click="markSelectedRead"> class="list-group-item list-group-item-action"
:disabled="!selectedHasUnread()"
@click="markSelectedRead"
>
<i class="bi bi-eye-fill me-1"></i> <i class="bi bi-eye-fill me-1"></i>
Mark read Mark read
</button> </button>
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()" <button
v-on:click="markSelectedUnread"> class="list-group-item list-group-item-action"
:disabled="!selectedHasRead()"
@click="markSelectedUnread"
>
<i class="bi bi-eye-slash me-1"></i> <i class="bi bi-eye-slash me-1"></i>
Mark unread Mark unread
</button> </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> <i class="bi bi-trash-fill me-1 text-danger"></i>
Delete selected Delete selected
</button> </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> <i class="bi bi-x-circle me-1"></i>
Cancel selection Cancel selection
</button> </button>

View File

@@ -1,7 +1,7 @@
<script> <script>
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
import { pagination } from '../stores/pagination' import { pagination } from "../stores/pagination";
export default { export default {
mixins: [CommonMixins], mixins: [CommonMixins],
@@ -10,79 +10,77 @@ export default {
return { return {
mailbox, mailbox,
pagination, pagination,
} };
}, },
methods: { methods: {
// test whether a tag is currently being searched for (in the URL) // test whether a tag is currently being searched for (in the URL)
inSearch(tag) { inSearch(tag) {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q') const query = urlParams.get("q");
if (!query) { if (!query) {
return false return false;
} }
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i') const re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, "i");
return query.match(re) return query.match(re);
}, },
// toggle a tag search in the search URL, add or remove it accordingly // toggle a tag search in the search URL, add or remove it accordingly
toggleTag(e, tag) { toggleTag(e, tag) {
e.preventDefault() e.preventDefault();
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search);
let query = urlParams.get('q') ? urlParams.get('q') : '' let query = urlParams.get("q") ? urlParams.get("q") : "";
let re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, 'i') const re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, "i");
if (query.match(re)) { if (query.match(re)) {
// remove is exists // remove is exists
query = query.replace(re, '$1$4') query = query.replace(re, "$1$4");
} else { } else {
// add to query // add to query
if (tag.match(/ /)) { if (tag.match(/ /)) {
tag = `"${tag}"` tag = `"${tag}"`;
} }
query = query + " tag:" + tag query = query + " tag:" + tag;
} }
query = query.trim() query = query.trim();
if (query == '') { if (query === "") {
this.$router.push('/') this.$router.push("/");
} else { } else {
const params = new URLSearchParams({ const params = new URLSearchParams({
q: query, q: query,
start: pagination.start.toString(), start: pagination.start.toString(),
limit: pagination.limit.toString(), limit: pagination.limit.toString(),
}) });
this.$router.push('/search?' + params.toString()) this.$router.push("/search?" + params.toString());
} }
}, },
toTagUrl(t) { toTagUrl(t) {
if (t.match(/ /)) { if (t.match(/ /)) {
t = `"${t}"` t = `"${t}"`;
} }
const p = { const p = {
q: 'tag:' + t q: "tag:" + t,
};
if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString();
} }
if (pagination.limit != pagination.defaultLimit) { const params = new URLSearchParams(p);
p.limit = pagination.limit.toString() return "/search?" + params.toString();
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
}, },
} },
} };
</script> </script>
<template> <template>
<template v-if="mailbox.tags && mailbox.tags.length"> <template v-if="mailbox.tags && mailbox.tags.length">
<div class="mt-4 text-muted"> <div class="mt-4 text-muted">
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Tags</button>
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal"> <button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
@@ -99,12 +97,20 @@ export default {
</ul> </ul>
</div> </div>
<div class="list-group mt-1 mb-2"> <div class="list-group mt-1 mb-2">
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav" <RouterLink
@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)" v-for="tag in mailbox.tags"
:key="tag"
:to="toTagUrl(tag)"
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''" :style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''"> class="list-group-item list-group-item-action small px-2"
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i> :class="inSearch(tag) ? 'active' : ''"
<i class="bi bi-tag" v-else></i> @click.exact="hideNav"
@click="pagination.start = 0"
@click.meta="toggleTag($event, tag)"
@click.ctrl="toggleTag($event, tag)"
>
<i v-if="inSearch(tag)" class="bi bi-tag-fill"></i>
<i v-else class="bi bi-tag"></i>
{{ tag }} {{ tag }}
</RouterLink> </RouterLink>
</div> </div>

View File

@@ -1,263 +0,0 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { Toast } from 'bootstrap'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() {
return {
pagination,
mailbox,
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
pauseNotifications: false, // prevent spamming
version: false,
clientErrors: [], // errors received via websocket
}
},
mounted() {
const d = document.getElementById('app')
if (d) {
this.version = d.dataset.version
}
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
this.socketBreakReset()
this.connect()
mailbox.notificationsSupported = window.isSecureContext
&& ("Notification" in window && Notification.permission !== "denied")
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
this.errorNotificationCron()
},
methods: {
// websocket connect
connect() {
const ws = new WebSocket(this.socketURI)
ws.onmessage = (e) => {
let response
try {
response = JSON.parse(e.data)
} catch (e) {
return
}
// new messages
if (response.Type == "new" && response.Data) {
this.eventBus.emit("new", response.Data)
for (let i in response.Data.Tags) {
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
mailbox.tags.push(response.Data.Tags[i])
mailbox.tags.sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase())
})
}
}
// send notifications
if (!this.pauseNotifications) {
this.pauseNotifications = true
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
this.browserNotify("New mail from: " + from, response.Data.Subject)
this.setMessageToast(response.Data)
// delay notifications by 2s
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
}
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
window.scrollInPlace = true
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
this.eventBus.emit("prune");
} else if (response.Type == "stats" && response.Data) {
// refresh mailbox stats
mailbox.total = response.Data.Total
mailbox.unread = response.Data.Unread
// detect version updated, refresh is needed
if (this.version != response.Data.Version) {
location.reload()
}
} else if (response.Type == "delete" && response.Data) {
// broadcast for components
this.eventBus.emit("delete", response.Data)
} else if (response.Type == "update" && response.Data) {
// broadcast for components
this.eventBus.emit("update", response.Data)
} else if (response.Type == "truncate") {
// broadcast for components
this.eventBus.emit("truncate")
} else if (response.Type == "error") {
// broadcast for components
this.addClientError(response.Data)
}
}
ws.onopen = () => {
mailbox.connected = true
this.socketLastConnection = Date.now()
if (this.reconnectRefresh) {
this.reconnectRefresh = false
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
}
}
ws.onclose = (e) => {
if (this.socketLastConnection == 0) {
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
console.log('Unable to connect to websocket, disabling websocket support')
return
}
if (mailbox.connected) {
// count disconnections
this.socketBreaks++
}
// set disconnected state
mailbox.connected = false
if (this.socketBreaks > 3) {
// give up after > 3 successful socket connections & disconnections within a 15 second window,
// something is not working right on their end, see issue #319
console.log('Unstable websocket connection, disabling websocket support')
return
}
if (Date.now() - this.socketLastConnection > 5000) {
// only refresh mailbox if the last successful connection was broken for > 5 seconds
this.reconnectRefresh = true
} else {
this.reconnectRefresh = false
}
setTimeout(() => {
this.connect() // reconnect
}, 1000)
}
ws.onerror = function (err) {
ws.close()
}
},
socketBreakReset() {
window.setTimeout(() => {
this.socketBreaks = 0
this.socketBreakReset()
}, 15000)
},
browserNotify(title, message) {
if (!("Notification" in window)) {
return
}
if (Notification.permission === "granted") {
let options = {
body: message,
icon: this.resolve('/notification.png')
}
new Notification(title, options)
}
},
setMessageToast(m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return
}
this.toastMessage = m
const el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
this.toastMessage = false
})
Toast.getOrCreateInstance(el).show()
}
},
closeToast() {
const el = document.getElementById('messageToast')
if (el) {
Toast.getOrCreateInstance(el).hide()
}
},
addClientError(d) {
d.expire = Date.now() + 5000 // expire after 5s
this.clientErrors.push(d)
},
errorNotificationCron() {
window.setTimeout(() => {
this.clientErrors.forEach((err, idx) => {
if (err.expire < Date.now()) {
this.clientErrors.splice(idx, 1)
}
})
this.errorNotificationCron()
}, 1000)
}
},
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
</svg>
<strong class="me-auto">{{ error.Type }}</strong>
<small class="text-body-secondary">{{ error.IP }}</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
{{ error.Message }}
</div>
</div>
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header" v-if="toastMessage">
<i class="bi bi-envelope-exclamation-fill me-2"></i>
<strong class="me-auto">
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div>
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
@click="closeToast">
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
<template v-else>
[ no subject ]
</template>
</RouterLink>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,107 +0,0 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { limitOptions, pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
props: {
total: Number,
},
data() {
return {
pagination,
mailbox,
limitOptions,
}
},
computed: {
canPrev() {
return pagination.start > 0
},
canNext() {
return this.total > (pagination.start + mailbox.messages.length)
},
// returns the number of next X messages
nextMessages() {
let t = pagination.start + parseInt(pagination.limit, 10)
if (t > this.total) {
t = this.total
}
return t
},
},
methods: {
changeLimit() {
pagination.start = 0
this.updateQueryParams()
},
viewNext() {
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
this.updateQueryParams()
},
viewPrev() {
let s = pagination.start - pagination.limit
if (s < 0) {
s = 0
}
pagination.start = s
this.updateQueryParams()
},
updateQueryParams() {
const path = this.$route.path
const p = {
...this.$route.query
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
} else {
delete p.start
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
} else {
delete p.limit
}
const params = new URLSearchParams(p)
this.$router.push(path + '?' + params.toString())
},
}
}
</script>
<template>
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
:disabled="total == 0">
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
</select>
<small>
<template v-if="total > 0">
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
<small>of</small>
{{ formatNumber(total) }}
</template>
<span v-else class="text-muted">0 of 0</span>
</small>
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
:title="'View previous ' + pagination.limit + ' messages'">
<i class="bi bi-caret-left-fill"></i>
</button>
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
:title="'View next ' + pagination.limit + ' messages'">
<i class="bi bi-caret-right-fill"></i>
</button>
</template>

View File

@@ -1,78 +1,84 @@
<script> <script>
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import { pagination } from '../stores/pagination' import { pagination } from "../stores/pagination";
export default { export default {
mixins: [CommonMixins], mixins: [CommonMixins],
emits: ['loadMessages'], emits: ["loadMessages"],
data() { data() {
return { return {
search: '' search: "",
} };
},
mounted() {
this.searchFromURL()
}, },
watch: { watch: {
$route() { $route() {
this.searchFromURL() this.searchFromURL();
} },
},
mounted() {
this.searchFromURL();
}, },
methods: { methods: {
searchFromURL() { searchFromURL() {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search);
this.search = urlParams.get('q') ? urlParams.get('q') : '' this.search = urlParams.get("q") ? urlParams.get("q") : "";
}, },
doSearch(e) { doSearch(e) {
pagination.start = 0 pagination.start = 0;
if (this.search == '') { if (this.search === "") {
this.$router.push('/') this.$router.push("/");
} else { } else {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search);
const curr = urlParams.get('q') const curr = urlParams.get("q");
if (curr && curr == this.search) { if (curr && curr === this.search) {
pagination.start = 0 pagination.start = 0;
this.$emit('loadMessages') this.$emit("loadMessages");
} }
const p = { const p = {
q: this.search q: this.search,
} };
if (pagination.start > 0) { if (pagination.start > 0) {
p.start = pagination.start.toString() p.start = pagination.start.toString();
} }
if (pagination.limit != pagination.defaultLimit) { if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString() p.limit = pagination.limit.toString();
} }
const params = new URLSearchParams(p) const params = new URLSearchParams(p);
this.$router.push('/search?' + params.toString()) this.$router.push("/search?" + params.toString());
} }
e.preventDefault() e.preventDefault();
}, },
resetSearch() { resetSearch() {
this.search = '' this.search = "";
this.$router.push('/') this.$router.push("/");
} },
} },
} };
</script> </script>
<template> <template>
<form v-on:submit="doSearch"> <form @submit="doSearch">
<div class="input-group flex-nowrap"> <div class="input-group flex-nowrap">
<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative"> <div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search" <input
placeholder="Search mailbox"> v-model.trim="search"
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''" type="text"
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span> class="form-control border-0"
aria-label="Search"
placeholder="Search mailbox"
/>
<span v-if="search != ''" class="btn btn-link position-absolute end-0 text-muted" @click="resetSearch"
><i class="bi bi-x-circle"></i
></span>
</div> </div>
<button class="btn btn-outline-secondary" type="submit"> <button class="btn btn-outline-secondary" type="submit">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>

View File

@@ -1,295 +0,0 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import Tags from 'bootstrap5-tags'
import timezones from 'timezones-list'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
data() {
return {
mailbox,
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
timezones,
chaosConfig: false,
chaosUpdated: false,
}
},
watch: {
theme(v) {
if (v == 'auto') {
localStorage.removeItem('theme')
} else {
localStorage.setItem('theme', v)
}
this.setTheme()
},
chaosConfig: {
handler() {
this.chaosUpdated = true
},
deep: true
},
'mailbox.skipConfirmations'(v) {
if (v) {
localStorage.setItem('skip-confirmations', 'true')
} else {
localStorage.removeItem('skip-confirmations')
}
}
},
mounted() {
this.setTheme()
this.$nextTick(function () {
Tags.init('select.tz')
})
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
},
methods: {
setTheme() {
if (
this.theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', this.theme)
}
},
loadChaos() {
this.get(this.resolve('/api/v1/chaos'), null, (response) => {
this.chaosConfig = response.data
this.$nextTick(() => {
this.chaosUpdated = false
})
})
},
saveChaos() {
this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => {
this.chaosConfig = response.data
this.$nextTick(() => {
this.chaosUpdated = false
})
})
}
}
}
</script>
<template>
<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true"
data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="SettingsModalLabel">Mailpit settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="ui-tab" data-bs-toggle="tab"
data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane"
aria-selected="true">Web UI</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="chaos-tab" data-bs-toggle="tab"
data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane"
aria-selected="false" @click="loadChaos">Chaos</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab"
tabindex="0">
<div class="my-3">
<label for="theme" class="form-label">Mailpit theme</label>
<select class="form-select" v-model="theme" id="theme">
<option value="auto">Auto (detect from browser)</option>
<option value="light">Light theme</option>
<option value="dark">Dark theme</option>
</select>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">Timezone (for date searches)</label>
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone"
data-allow-same="true">
<option disabled hidden value="">Select a timezone...</option>
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
</select>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
v-model="mailbox.showTagColors">
<label class="form-check-label" for="tagColors">
Use auto-generated tag colors
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
v-model="mailbox.showHTMLCheck">
<label class="form-check-label" for="htmlCheck">
Show HTML check message tab
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
v-model="mailbox.showLinkCheck">
<label class="form-check-label" for="linkCheck">
Show link check message tab
</label>
</div>
</div>
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
v-model="mailbox.showSpamCheck">
<label class="form-check-label" for="spamCheck">
Show spam check message tab
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="skip-confirmations" v-model="mailbox.skipConfirmations">
<label class="form-check-label" for="skip-confirmations">
Skip
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
<code>Delete all</code> &amp;
</template>
<code>Mark all read</code> confirmation dialogs
</label>
</div>
</div>
</div>
<div class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab"
tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled">
<p class="my-3">
<b>Chaos</b> allows you to set random SMTP failures and response codes at various
stages in a SMTP transaction to test application resilience
(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank">
see documentation
</a>).
</p>
<ul>
<li>
<code>Response code</code> is the SMTP error code returned by the server if this
error is triggered. Error codes must range between 400 and 599.
</li>
<li>
<code>Error probability</code> is the % chance that the error will occur per message
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
trigger. A probability of <code>50</code> will trigger on approximately 50% of
messages received.
</li>
</ul>
<template v-if="chaosConfig">
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
<div class="mb-4">
<label>Trigger: <code>Sender</code></label>
<div class="form-text">
Trigger an error response based on the sender (From / Sender).
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label">
Response code
</label>
<input type="number" class="form-control"
v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599"
required>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Sender.Probability }}%)
</label>
<input type="range" class="form-range mt-1" min="0" max="100"
v-model.number="chaosConfig.Sender.Probability">
</div>
</div>
</div>
<div class="mb-4">
<label>Trigger: <code>Recipient</code></label>
<div class="form-text">
Trigger an error response based on the recipients (To, Cc, Bcc).
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label">
Response code
</label>
<input type="number" class="form-control"
v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599"
required>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Recipient.Probability }}%)
</label>
<input type="range" class="form-range mt-1" min="0" max="100"
v-model.number="chaosConfig.Recipient.Probability">
</div>
</div>
</div>
<div class="mb-4">
<label>Trigger: <code>Authentication</code></label>
<div class="form-text">
Trigger an authentication error response.
Note that SMTP authentication must be configured too.
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label">
Response code
</label>
<input type="number" class="form-control"
v-model.number="chaosConfig.Authentication.ErrorCode" min="400"
max="599" required>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Authentication.Probability }}%)
</label>
<input type="range" class="form-range mt-1" min="0" max="100"
v-model.number="chaosConfig.Authentication.Probability">
</div>
</div>
</div>
</div>
<div v-if="chaosUpdated" class="mb-3 text-center">
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
</div>
</template>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,221 +1,234 @@
<script> <script>
import { VcDonut } from 'vue-css-donut-chart' import { VcDonut } from "vue-css-donut-chart";
import axios from 'axios' import axios from "axios";
import commonMixins from '../../mixins/CommonMixins' import commonMixins from "../../mixins/CommonMixins";
import { Tooltip } from 'bootstrap' import { Tooltip } from "bootstrap";
import DOMPurify from "dompurify";
export default { export default {
props: {
message: Object,
},
components: { components: {
VcDonut, VcDonut,
}, },
emits: ["setHtmlScore", "setBadgeStyle"],
mixins: [commonMixins], mixins: [commonMixins],
props: {
message: {
type: Object,
required: true,
},
},
emits: ["setHtmlScore", "setBadgeStyle"],
data() { data() {
return { return {
error: false, error: false,
check: false, check: false,
platforms: [], platforms: [],
allPlatforms: { allPlatforms: {
"windows": "Windows", windows: "Windows",
"windows-mail": "Windows Mail", "windows-mail": "Windows Mail",
"outlook-com": "Outlook.com", "outlook-com": "Outlook.com",
"macos": "macOS", macos: "macOS",
"ios": "iOS", ios: "iOS",
"android": "Android", android: "Android",
"desktop-webmail": "Desktop Webmail", "desktop-webmail": "Desktop Webmail",
"mobile-webmail": "Mobile Webmail", "mobile-webmail": "Mobile Webmail",
}, },
} };
},
mounted() {
this.loadConfig()
this.doCheck()
}, },
computed: { computed: {
summary() { summary() {
if (!this.check) { if (!this.check) {
return false return false;
} }
let result = { const result = {
Warnings: [], Warnings: [],
Total: { Total: {
Nodes: this.check.Total.Nodes Nodes: this.check.Total.Nodes,
} },
} };
for (let i = 0; i < this.check.Warnings.length; i++) { for (let i = 0; i < this.check.Warnings.length; i++) {
let o = JSON.parse(JSON.stringify(this.check.Warnings[i])) const o = JSON.parse(JSON.stringify(this.check.Warnings[i]));
// for <script> test // for <script> test
if (o.Results.length == 0) { if (o.Results.length === 0) {
result.Warnings.push(o) result.Warnings.push(o);
continue continue;
} }
// filter by enabled platforms // filter by enabled platforms
let results = o.Results.filter((w) => { const results = o.Results.filter((w) => {
return this.platforms.indexOf(w.Platform) != -1 return this.platforms.indexOf(w.Platform) !== -1;
}) });
if (results.length == 0) { if (results.length === 0) {
continue continue;
} }
// recalculate the percentages // recalculate the percentages
let y = 0, p = 0, n = 0 let y = 0;
let p = 0;
let n = 0;
results.forEach(function (r) { results.forEach((r) => {
if (r.Support == "yes") { if (r.Support === "yes") {
y++ y++;
} else if (r.Support == "partial") { } else if (r.Support === "partial") {
p++ p++;
} else { } else {
n++ n++;
} }
}) });
let total = y + p + n const total = y + p + n;
o.Results = results o.Results = results;
o.Score = { o.Score = {
Found: o.Score.Found, Found: o.Score.Found,
Supported: y / total * 100, Supported: (y / total) * 100,
Partial: p / total * 100, Partial: (p / total) * 100,
Unsupported: n / total * 100 Unsupported: (n / total) * 100,
};
result.Warnings.push(o);
} }
result.Warnings.push(o) let maxPartial = 0;
} let maxUnsupported = 0;
let maxPartial = 0, maxUnsupported = 0
result.Warnings.forEach((w) => { result.Warnings.forEach((w) => {
let scoreWeight = 1 let scoreWeight = 1;
if (w.Score.Found < result.Total.Nodes) { if (w.Score.Found < result.Total.Nodes) {
// each error is weighted based on the number of occurrences vs: the total message nodes // each error is weighted based on the number of occurrences vs: the total message nodes
scoreWeight = w.Score.Found / result.Total.Nodes scoreWeight = w.Score.Found / result.Total.Nodes;
} }
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they // pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
// are actually used in the HTML, and including things like bootstrap styles completely throws // are actually used in the HTML, and including things like bootstrap styles completely throws
// off the calculation as these dominate. // off the calculation as these dominate.
if (this.isPseudoClassOrAtRule(w.Title)) { if (this.isPseudoClassOrAtRule(w.Title)) {
scoreWeight = 0.05 scoreWeight = 0.05;
w.PseudoClassOrAtRule = true w.PseudoClassOrAtRule = true;
} }
let scorePartial = w.Score.Partial * scoreWeight const scorePartial = w.Score.Partial * scoreWeight;
let scoreUnsupported = w.Score.Unsupported * scoreWeight const scoreUnsupported = w.Score.Unsupported * scoreWeight;
if (scorePartial > maxPartial) { if (scorePartial > maxPartial) {
maxPartial = scorePartial maxPartial = scorePartial;
} }
if (scoreUnsupported > maxUnsupported) { if (scoreUnsupported > maxUnsupported) {
maxUnsupported = scoreUnsupported maxUnsupported = scoreUnsupported;
} }
}) });
// sort warnings by final score // sort warnings by final score
result.Warnings.sort((a, b) => { result.Warnings.sort((a, b) => {
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes let aWeight =
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes;
let bWeight =
b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes;
if (this.isPseudoClassOrAtRule(a.Title)) { if (this.isPseudoClassOrAtRule(a.Title)) {
aWeight = 0.05 aWeight = 0.05;
} }
if (this.isPseudoClassOrAtRule(b.Title)) { if (this.isPseudoClassOrAtRule(b.Title)) {
bWeight = 0.05 bWeight = 0.05;
} }
return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight return (
}) (a.Score.Unsupported + a.Score.Partial) * aWeight <
(b.Score.Unsupported + b.Score.Partial) * bWeight
);
});
result.Total.Supported = 100 - maxPartial - maxUnsupported result.Total.Supported = 100 - maxPartial - maxUnsupported;
result.Total.Partial = maxPartial result.Total.Partial = maxPartial;
result.Total.Unsupported = maxUnsupported result.Total.Unsupported = maxUnsupported;
this.$emit('setHtmlScore', result.Total.Supported) this.$emit("setHtmlScore", result.Total.Supported);
return result return result;
}, },
graphSections() { graphSections() {
let s = Math.round(this.summary.Total.Supported) const s = Math.round(this.summary.Total.Supported);
let p = Math.round(this.summary.Total.Partial) const p = Math.round(this.summary.Total.Partial);
let u = 100 - s - p const u = 100 - s - p;
return [ return [
{ {
label: this.round2dm(this.summary.Total.Supported) + '% supported', label: this.round2dm(this.summary.Total.Supported) + "% supported",
value: s, value: s,
color: '#198754' color: "#198754",
}, },
{ {
label: this.round2dm(this.summary.Total.Partial) + '% partially supported', label: this.round2dm(this.summary.Total.Partial) + "% partially supported",
value: p, value: p,
color: '#ffc107' color: "#ffc107",
}, },
{ {
label: this.round2dm(this.summary.Total.Unsupported) + '% not supported', label: this.round2dm(this.summary.Total.Unsupported) + "% not supported",
value: u, value: u,
color: '#dc3545' color: "#dc3545",
} },
] ];
}, },
// colors depend on both varying unsupported & partially unsupported percentages // colors depend on both varying unsupported & partially unsupported percentages
scoreColor() { scoreColor() {
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) { if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
this.$emit('setBadgeStyle', 'bg-success') this.$emit("setBadgeStyle", "bg-success");
return 'text-success' return "text-success";
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) { } else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
this.$emit('setBadgeStyle', 'bg-warning text-primary') this.$emit("setBadgeStyle", "bg-warning text-primary");
return 'text-warning' return "text-warning";
} }
this.$emit('setBadgeStyle', 'bg-danger') this.$emit("setBadgeStyle", "bg-danger");
return 'text-danger' return "text-danger";
} },
}, },
watch: { watch: {
message: { message: {
handler() { handler() {
this.$emit('setHtmlScore', false) this.$emit("setHtmlScore", false);
this.doCheck() this.doCheck();
}, },
deep: true deep: true,
}, },
platforms(v) { platforms(v) {
localStorage.setItem('html-check-platforms', JSON.stringify(v)) localStorage.setItem("html-check-platforms", JSON.stringify(v));
}, },
}, },
mounted() {
this.loadConfig();
this.doCheck();
},
methods: { methods: {
doCheck() { doCheck() {
this.check = false this.check = false;
if (this.message.HTML == "") { if (this.message.HTML === "") {
return return;
} }
// ignore any error, do not show loader // ignore any error, do not show loader
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null) axios
.get(this.resolve("/api/v1/message/" + this.message.ID + "/html-check"), null)
.then((result) => { .then((result) => {
this.check = result.data this.check = result.data;
this.error = false this.error = false;
// set tooltips // set tooltips
window.setTimeout(() => { window.setTimeout(() => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) [...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
}, 500) }, 500);
}) })
.catch((error) => { .catch((error) => {
// handle error // handle error
@@ -223,68 +236,72 @@ export default {
// The request was made and the server responded with a status code // The request was made and the server responded with a status code
// that falls out of the range of 2xx // that falls out of the range of 2xx
if (error.response.data.Error) { if (error.response.data.Error) {
this.error = error.response.data.Error this.error = error.response.data.Error;
} else { } else {
this.error = error.response.data this.error = error.response.data;
} }
} else if (error.request) { } else if (error.request) {
// The request was made but no response was received // The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js // http.ClientRequest in node.js
this.error = 'Error sending data to the server. Please try again.' this.error = "Error sending data to the server. Please try again.";
} else { } else {
// Something happened in setting up the request that triggered an Error // Something happened in setting up the request that triggered an Error
this.error = error.message this.error = error.message;
} }
}) });
}, },
loadConfig() { loadConfig() {
let platforms = localStorage.getItem('html-check-platforms') const platforms = localStorage.getItem("html-check-platforms");
if (platforms) { if (platforms) {
try { try {
this.platforms = JSON.parse(platforms) this.platforms = JSON.parse(platforms);
} catch (e) { } catch (e) {}
}
} }
// set all options // set all options
if (this.platforms.length == 0) { if (this.platforms.length === 0) {
this.platforms = Object.keys(this.allPlatforms) this.platforms = Object.keys(this.allPlatforms);
} }
}, },
// return a platform's families (email clients) // return a platform's families (email clients)
families(k) { families(k) {
if (this.check.Platforms[k]) { if (this.check.Platforms[k]) {
return this.check.Platforms[k] return this.check.Platforms[k];
} }
return [] return [];
}, },
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>) // return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
isPseudoClassOrAtRule(t) { isPseudoClassOrAtRule(t) {
return t.match(/^(:|@)/) return t.match(/^(:|@)/);
}, },
round(v) { round(v) {
return Math.round(v) return Math.round(v);
}, },
round2dm(v) { round2dm(v) {
return Math.round(v * 100) / 100 return Math.round(v * 100) / 100;
}, },
scrollToWarnings() { scrollToWarnings() {
if (!this.$refs.warnings) { if (!this.$refs.warnings) {
return return;
} }
this.$refs.warnings.scrollIntoView({ behavior: "smooth" }) this.$refs.warnings.scrollIntoView({ behavior: "smooth" });
}, },
}
} // Sanitize HTML to prevent XSS
sanitizeHTML(html) {
return DOMPurify.sanitize(html);
},
},
};
</script> </script>
<template> <template>
@@ -299,39 +316,50 @@ export default {
<div class="mt-5 mb-3"> <div class="mt-5 mb-3">
<div class="row w-100"> <div class="row w-100">
<div class="col-md-8"> <div class="col-md-8">
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" <vc-donut
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0" :sections="graphSections"
:auto-adjust-text-size="true" @section-click="scrollToWarnings"> background="var(--bs-body-bg)"
:size="180"
unit="px"
:thickness="20"
has-legend
legend-placement="bottom"
:total="100"
:start-angle="0"
:auto-adjust-text-size="true"
@section-click="scrollToWarnings"
>
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings"> <h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
{{ round2dm(summary.Total.Supported) }}% {{ round2dm(summary.Total.Supported) }}%
</h2> </h2>
<div class="text-body"> <div class="text-body">support</div>
support
</div>
<template #legend> <template #legend>
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings"> <p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
<span class="text-nowrap"> <span class="text-nowrap">
<i class="bi bi-circle-fill text-success"></i> <i class="bi bi-circle-fill text-success"></i>
{{ round2dm(summary.Total.Supported) }}% supported {{ round2dm(summary.Total.Supported) }}% supported
</span> &nbsp; </span>
&nbsp;
<span class="text-nowrap"> <span class="text-nowrap">
<i class="bi bi-circle-fill text-warning"></i> <i class="bi bi-circle-fill text-warning"></i>
{{ round2dm(summary.Total.Partial) }}% partially supported {{ round2dm(summary.Total.Partial) }}% partially supported
</span> &nbsp; </span>
&nbsp;
<span class="text-nowrap"> <span class="text-nowrap">
<i class="bi bi-circle-fill text-danger"></i> <i class="bi bi-circle-fill text-danger"></i>
{{ round2dm(summary.Total.Unsupported) }}% not supported {{ round2dm(summary.Total.Unsupported) }}% not supported
</span> </span>
</p> </p>
<p class="small text-muted"> <p class="small text-muted">calculated from {{ formatNumber(check.Total.Tests) }} tests</p>
calculated from {{ formatNumber(check.Total.Tests) }} tests
</p>
</template> </template>
</vc-donut> </vc-donut>
<div class="input-group justify-content-center mb-3"> <div class="input-group justify-content-center mb-3">
<button class="btn btn-outline-secondary" data-bs-toggle="modal" <button
data-bs-target="#AboutHTMLCheckResults"> class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#AboutHTMLCheckResults"
>
<i class="bi bi-info-circle-fill"></i> <i class="bi bi-info-circle-fill"></i>
Help Help
</button> </button>
@@ -339,12 +367,24 @@ export default {
</div> </div>
<div class="col-md"> <div class="col-md">
<h2 class="h5 mb-3">Tested platforms:</h2> <h2 class="h5 mb-3">Tested platforms:</h2>
<div class="form-check form-switch" v-for="p, k in allPlatforms"> <div v-for="(p, k) in allPlatforms" :key="'check_' + k" class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms" <input
:aria-label="p" :id="'Check_' + k"> :id="'Check_' + k"
<label class="form-check-label" :for="'Check_' + k" v-model="platforms"
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'" :title="families(k).join(', ')" class="form-check-input"
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')"> type="checkbox"
role="switch"
:value="k"
:aria-label="p"
/>
<label
class="form-check-label"
:for="'Check_' + k"
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'"
:title="families(k).join(', ')"
data-bs-toggle="tooltip"
:data-bs-title="families(k).join(', ')"
>
{{ p }} {{ p }}
</label> </label>
</div> </div>
@@ -356,45 +396,72 @@ export default {
<h4 ref="warnings" class="h5 mt-4"> <h4 ref="warnings" class="h5 mt-4">
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes: {{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
</h4> </h4>
<div class="accordion" id="warnings"> <div id="warnings" class="accordion">
<div class="accordion-item" v-for="warning in summary.Warnings"> <div v-for="(warning, i) in summary.Warnings" :key="'warning_' + i" class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
:data-bs-target="'#' + warning.Slug"
aria-expanded="false"
:aria-controls="warning.Slug"
>
<div class="row w-100 w-lg-75"> <div class="row w-100 w-lg-75">
<div class="col-sm"> <div class="col-sm">
{{ warning.Title }} {{ warning.Title }}
<span class="ms-2 small badge text-bg-secondary" title="Test category"> <span class="ms-2 small badge text-bg-secondary" title="Test category">
{{ warning.Category }} {{ warning.Category }}
</span> </span>
<span class="ms-2 small badge text-bg-light" <span
title="The number of times this was detected"> class="ms-2 small badge text-bg-light"
title="The number of times this was detected"
>
x {{ warning.Score.Found }} x {{ warning.Score.Found }}
</span> </span>
</div> </div>
<div class="col-sm mt-2 mt-sm-0"> <div class="col-sm mt-2 mt-sm-0">
<div class="progress-stacked"> <div class="progress-stacked">
<div class="progress" role="progressbar" aria-label="Supported" <div
:aria-valuenow="warning.Score.Supported" aria-valuemin="0" class="progress"
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }" role="progressbar"
title="Supported"> aria-label="Supported"
:aria-valuenow="warning.Score.Supported"
aria-valuemin="0"
aria-valuemax="100"
:style="{ width: warning.Score.Supported + '%' }"
title="Supported"
>
<div class="progress-bar bg-success"> <div class="progress-bar bg-success">
{{ round(warning.Score.Supported) + '%' }} {{ round(warning.Score.Supported) + "%" }}
</div> </div>
</div> </div>
<div class="progress" role="progressbar" aria-label="Partial" <div
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100" class="progress"
:style="{ width: warning.Score.Partial + '%' }" title="Partial support"> role="progressbar"
aria-label="Partial"
:aria-valuenow="warning.Score.Partial"
aria-valuemin="0"
aria-valuemax="100"
:style="{ width: warning.Score.Partial + '%' }"
title="Partial support"
>
<div class="progress-bar progress-bar-striped bg-warning text-dark"> <div class="progress-bar progress-bar-striped bg-warning text-dark">
{{ round(warning.Score.Partial) + '%' }} {{ round(warning.Score.Partial) + "%" }}
</div> </div>
</div> </div>
<div class="progress" role="progressbar" aria-label="No" <div
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" class="progress"
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }" role="progressbar"
title="Not supported"> aria-label="No"
:aria-valuenow="warning.Score.Unsupported"
aria-valuemin="0"
aria-valuemax="100"
:style="{ width: warning.Score.Unsupported + '%' }"
title="Not supported"
>
<div class="progress-bar bg-danger"> <div class="progress-bar bg-danger">
{{ round(warning.Score.Unsupported) + '%' }} {{ round(warning.Score.Unsupported) + "%" }}
</div> </div>
</div> </div>
</div> </div>
@@ -404,28 +471,45 @@ export default {
</h2> </h2>
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings"> <div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
<div class="accordion-body"> <div class="accordion-body">
<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule"> <p v-if="warning.Description !== '' || warning.PseudoClassOrAtRule">
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2"> <span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
<i class="bi bi-info-circle me-2"></i> <i class="bi bi-info-circle me-2"></i>
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code> Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
propert<template v-if="warning.Score.Found === 1">y</template><template <template v-if="warning.Score.Found === 1">property</template>
v-else>ies</template> in the CSS <template v-else>properties</template>
styles, but unable to test if used or not. in the CSS styles, but unable to test if used or not.
</span> </span>
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span> <!-- eslint-disable vue/no-v-html -->
<span
v-if="warning.Description !== ''"
class="me-2"
v-html="sanitizeHTML(warning.Description)"
></span>
<!-- -eslint-disable vue/no-v-html -->
</p> </p>
<template v-if="warning.Results.length"> <template v-if="warning.Results.length">
<h3 class="h6">Clients with partial or no support:</h3> <h3 class="h6">Clients with partial or no support:</h3>
<p> <p>
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4"> <small
<i class="bi bi-circle-fill" v-for="(warningRes, wi) in warning.Results"
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'" :key="'warning_results_' + wi"
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i> class="text-nowrap d-inline-block me-4"
{{ warning.Name }} >
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''" <i
title="See notes"> class="bi bi-circle-fill"
{{ warning.NoteNumber }} :class="warningRes.Support === 'no' ? 'text-danger' : 'text-warning'"
:title="
warningRes.Support === 'no' ? 'Not supported' : 'Partially supported'
"
></i>
{{ warningRes.Name }}
<span
v-if="warningRes.NoteNumber !== ''"
class="badge text-bg-secondary"
title="See notes"
>
{{ warningRes.NoteNumber }}
</span> </span>
</small> </small>
</p> </p>
@@ -433,17 +517,21 @@ export default {
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3"> <div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
<h3 class="h6">Notes:</h3> <h3 class="h6">Notes:</h3>
<div v-for="n, i in warning.NotesByNumber" class="small row my-2"> <div
v-for="(n, ni) in warning.NotesByNumber"
:key="'warning_notes' + ni"
class="small row my-2"
>
<div class="col-auto pe-0"> <div class="col-auto pe-0">
<span class="badge text-bg-secondary"> <span class="badge text-bg-secondary">
{{ i }} {{ ni }}
</span> </span>
</div> </div>
<div class="col" v-html="n"></div> <div class="col" v-html="sanitizeHTML(n)"></div>
</div> </div>
</div> </div>
<p class="small mt-3 mb-0" v-if="warning.URL"> <p v-if="warning.URL" class="small mt-3 mb-0">
<a :href="warning.URL" target="_blank">Online reference</a> <a :href="warning.URL" target="_blank">Online reference</a>
</p> </p>
</div> </div>
@@ -452,30 +540,44 @@ export default {
</div> </div>
<p class="text-center text-muted small mt-4"> <p class="text-center text-muted small mt-4">
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using compatibility data
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>. from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
</p> </p>
</template> </template>
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel" <div
aria-hidden="true"> id="AboutHTMLCheckResults"
class="modal fade"
tabindex="-1"
aria-labelledby="AboutHTMLCheckResultsLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1> <h1 id="AboutHTMLCheckResultsLabel" class="modal-title fs-5">About HTML check</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="accordion" id="HTMLCheckAboutAccordion"> <div id="HTMLCheckAboutAccordion" class="accordion">
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col1" aria-expanded="false" aria-controls="col1"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col1"
aria-expanded="false"
aria-controls="col1"
>
What is HTML check? What is HTML check?
</button> </button>
</h2> </h2>
<div id="col1" class="accordion-collapse collapse" <div
data-bs-parent="#HTMLCheckAboutAccordion"> id="col1"
class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
The support for HTML/CSS messages varies greatly across email clients. HTML The support for HTML/CSS messages varies greatly across email clients. HTML
check attempts to calculate the overall support for your email for all selected check attempts to calculate the overall support for your email for all selected
@@ -485,13 +587,22 @@ export default {
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col2" aria-expanded="false" aria-controls="col2"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col2"
aria-expanded="false"
aria-controls="col2"
>
How does it work? How does it work?
</button> </button>
</h2> </h2>
<div id="col2" class="accordion-collapse collapse" <div
data-bs-parent="#HTMLCheckAboutAccordion"> id="col2"
class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
Internally the original HTML message is run against Internally the original HTML message is run against
@@ -504,10 +615,11 @@ export default {
CSS support is very difficult to programmatically test, especially if a CSS support is very difficult to programmatically test, especially if a
message contains CSS style blocks or is linked to remote stylesheets. Remote message contains CSS style blocks or is linked to remote stylesheets. Remote
stylesheets are, unless blocked via stylesheets are, unless blocked via
<code>--block-remote-css-and-fonts</code>, <code>--block-remote-css-and-fonts</code>, downloaded and injected into the
downloaded and injected into the message as style blocks. The email is then message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer" <a href="https://github.com/vanng822/go-premailer" target="_blank"
target="_blank">inlined</a> >inlined</a
>
to matching HTML elements. This gives Mailpit fairly accurate results. to matching HTML elements. This gives Mailpit fairly accurate results.
</p> </p>
<p> <p>
@@ -528,13 +640,22 @@ export default {
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col3" aria-expanded="false" aria-controls="col3"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col3"
aria-expanded="false"
aria-controls="col3"
>
Is the final score accurate? Is the final score accurate?
</button> </button>
</h2> </h2>
<div id="col3" class="accordion-collapse collapse" <div
data-bs-parent="#HTMLCheckAboutAccordion"> id="col3"
class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
There are many ways to define "accurate", and how one should calculate the There are many ways to define "accurate", and how one should calculate the
@@ -578,13 +699,22 @@ export default {
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col4" aria-expanded="false" aria-controls="col4"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col4"
aria-expanded="false"
aria-controls="col4"
>
What about invalid HTML? What about invalid HTML?
</button> </button>
</h2> </h2>
<div id="col4" class="accordion-collapse collapse" <div
data-bs-parent="#HTMLCheckAboutAccordion"> id="col4"
class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
HTML check does not detect if the original HTML is valid. In order to detect HTML check does not detect if the original HTML is valid. In order to detect
applied styles to every node, the HTML email is run through a parser which is applied styles to every node, the HTML email is run through a parser which is
@@ -592,7 +722,6 @@ export default {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -1,36 +0,0 @@
<script>
import commonMixins from '../../mixins/CommonMixins'
export default {
props: {
message: Object
},
mixins: [commonMixins],
data() {
return {
headers: false
}
},
mounted() {
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
this.get(uri, false, (response) => {
this.headers = response.data
});
},
}
</script>
<template>
<div v-if="headers" class="small">
<div v-for="values, k in headers" class="row mb-2 pb-2 border-bottom w-100">
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
</div>
</div>
</div>
</template>

View File

@@ -1,16 +1,19 @@
<script> <script>
import axios from 'axios' import axios from "axios";
import commonMixins from '../../mixins/CommonMixins' import commonMixins from "../../mixins/CommonMixins";
export default { export default {
mixins: [commonMixins],
props: { props: {
message: Object, message: {
type: Object,
required: true,
},
}, },
emits: ["setLinkErrors"], emits: ["setLinkErrors"],
mixins: [commonMixins],
data() { data() {
return { return {
error: false, error: false,
@@ -19,116 +22,116 @@ export default {
check: false, check: false,
loaded: false, loaded: false,
loading: false, loading: false,
} };
},
created() {
this.autoScan = localStorage.getItem('LinkCheckAutoScan')
this.followRedirects = localStorage.getItem('LinkCheckFollowRedirects')
},
mounted() {
this.loaded = true
if (this.autoScan) {
this.doCheck()
}
},
watch: {
autoScan(v) {
if (!this.loaded) {
return
}
if (v) {
localStorage.setItem('LinkCheckAutoScan', true)
if (!this.check) {
this.doCheck()
}
} else {
localStorage.removeItem('LinkCheckAutoScan')
}
},
followRedirects(v) {
if (!this.loaded) {
return
}
if (v) {
localStorage.setItem('LinkCheckFollowRedirects', true)
} else {
localStorage.removeItem('LinkCheckFollowRedirects')
}
if (this.check) {
this.doCheck()
}
}
}, },
computed: { computed: {
groupedStatuses() { groupedStatuses() {
let results = {} const results = {};
if (!this.check) { if (!this.check) {
return results return results;
} }
// group by status // group by status
this.check.Links.forEach(function (r) { this.check.Links.forEach((r) => {
if (!results[r.StatusCode]) { if (!results[r.StatusCode]) {
let css = "" let css = "";
if (r.StatusCode >= 400 || r.StatusCode === 0) { if (r.StatusCode >= 400 || r.StatusCode === 0) {
css = "text-danger" css = "text-danger";
} else if (r.StatusCode >= 300) { } else if (r.StatusCode >= 300) {
css = "text-info" css = "text-info";
} }
if (r.StatusCode === 0) { if (r.StatusCode === 0) {
r.Status = 'Cannot connect to server' r.Status = "Cannot connect to server";
} }
results[r.StatusCode] = { results[r.StatusCode] = {
StatusCode: r.StatusCode, StatusCode: r.StatusCode,
Status: r.Status, Status: r.Status,
Class: css, Class: css,
URLS: [] URLS: [],
};
} }
} results[r.StatusCode].URLS.push(r.URL);
results[r.StatusCode].URLS.push(r.URL) });
})
let newArr = [] const newArr = [];
for (const i in results) { for (const i in results) {
newArr.push(results[i]) newArr.push(results[i]);
} }
// sort statuses // sort statuses
let sorted = newArr.sort((a, b) => { const sorted = newArr.sort((a, b) => {
if (a.StatusCode === 0) { if (a.StatusCode === 0) {
return false return false;
} }
return a.StatusCode < b.StatusCode return a.StatusCode < b.StatusCode;
}) });
return sorted;
},
},
return sorted watch: {
autoScan(v) {
if (!this.loaded) {
return;
}
if (v) {
localStorage.setItem("LinkCheckAutoScan", true);
if (!this.check) {
this.doCheck();
}
} else {
localStorage.removeItem("LinkCheckAutoScan");
}
},
followRedirects(v) {
if (!this.loaded) {
return;
}
if (v) {
localStorage.setItem("LinkCheckFollowRedirects", true);
} else {
localStorage.removeItem("LinkCheckFollowRedirects");
}
if (this.check) {
this.doCheck();
}
},
},
created() {
this.autoScan = localStorage.getItem("LinkCheckAutoScan");
this.followRedirects = localStorage.getItem("LinkCheckFollowRedirects");
},
mounted() {
this.loaded = true;
if (this.autoScan) {
this.doCheck();
} }
}, },
methods: { methods: {
doCheck() { doCheck() {
this.check = false this.check = false;
this.loading = true this.loading = true;
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check') let uri = this.resolve("/api/v1/message/" + this.message.ID + "/link-check");
if (this.followRedirects) { if (this.followRedirects) {
uri += '?follow=true' uri += "?follow=true";
} }
// ignore any error, do not show loader // ignore any error, do not show loader
axios.get(uri, null) axios
.get(uri, null)
.then((result) => { .then((result) => {
this.check = result.data this.check = result.data;
this.error = false this.error = false;
this.$emit('setLinkErrors', result.data.Errors) this.$emit("setLinkErrors", result.data.Errors);
}) })
.catch((error) => { .catch((error) => {
// handle error // handle error
@@ -136,27 +139,27 @@ export default {
// The request was made and the server responded with a status code // The request was made and the server responded with a status code
// that falls out of the range of 2xx // that falls out of the range of 2xx
if (error.response.data.Error) { if (error.response.data.Error) {
this.error = error.response.data.Error this.error = error.response.data.Error;
} else { } else {
this.error = error.response.data this.error = error.response.data;
} }
} else if (error.request) { } else if (error.request) {
// The request was made but no response was received // The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js // http.ClientRequest in node.js
this.error = 'Error sending data to the server. Please try again.' this.error = "Error sending data to the server. Please try again.";
} else { } else {
// Something happened in setting up the request that triggered an Error // Something happened in setting up the request that triggered an Error
this.error = error.message this.error = error.message;
} }
}) })
.then((result) => { .then((result) => {
// always run // always run
this.loading = false this.loading = false;
}) });
}, },
} },
} };
</script> </script>
<template> <template>
@@ -164,24 +167,24 @@ export default {
<div class="row mb-3 align-items-center"> <div class="row mb-3 align-items-center">
<div class="col"> <div class="col">
<h4 class="mb-0"> <h4 class="mb-0">
<template v-if="!check"> <template v-if="!check"> Link check </template>
Link check
</template>
<template v-else> <template v-else>
<template v-if="check.Links.length"> <template v-if="check.Links.length">
Scanned {{ formatNumber(check.Links.length) }} Scanned {{ formatNumber(check.Links.length) }} link<template v-if="check.Links.length != 1"
link<template v-if="check.Links.length != 1">s</template> >s</template
</template> >
<template v-else>
No links detected
</template> </template>
<template v-else> No links detected </template>
</template> </template>
</h4> </h4>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div class="input-group"> <div class="input-group">
<button class="btn btn-outline-secondary" data-bs-toggle="modal" <button
data-bs-target="#AboutLinkCheckResults"> class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#AboutLinkCheckResults"
>
<i class="bi bi-info-circle-fill"></i> <i class="bi bi-info-circle-fill"></i>
Help Help
</button> </button>
@@ -195,12 +198,12 @@ export default {
<div v-if="!check"> <div v-if="!check">
<p class="text-muted"> <p class="text-muted">
Link check scans your email text &amp; HTML for unique links, testing the response status codes. Link check scans your email text &amp; HTML for unique links, testing the response status codes. This
This includes links to images and remote CSS stylesheets. includes links to images and remote CSS stylesheets.
</p> </p>
<p class="text-center my-5"> <p class="text-center my-5">
<button v-if="!check" class="btn btn-primary btn-lg" @click="doCheck()" :disabled="loading"> <button v-if="!check" class="btn btn-primary btn-lg" :disabled="loading" @click="doCheck()">
<template v-if="loading"> <template v-if="loading">
Checking links Checking links
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status"> <div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
@@ -215,14 +218,14 @@ export default {
</p> </p>
</div> </div>
<div v-else v-for="s, k in groupedStatuses"> <div v-for="(s, k) in groupedStatuses" v-else :key="k">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header h4" :class="s.Class"> <div class="card-header h4" :class="s.Class">
Status {{ s.StatusCode }} Status {{ s.StatusCode }}
<small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small> <small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small>
</div> </div>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li v-for="u in s.URLS" class="list-group-item"> <li v-for="(u, i) in s.URLS" :key="'status' + i" class="list-group-item">
<a :href="u" target="_blank" class="no-icon">{{ u }}</a> <a :href="u" target="_blank" class="no-icon">{{ u }}</a>
</li> </li>
</ul> </ul>
@@ -235,22 +238,31 @@ export default {
{{ error }} {{ error }}
</div> </div>
</template> </template>
</div> </div>
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel" <div
aria-hidden="true"> id="LinkCheckOptions"
class="modal fade"
tabindex="-1"
aria-labelledby="LinkCheckOptionsLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="LinkCheckOptionsLabel">Link check options</h1> <h1 id="LinkCheckOptionsLabel" class="modal-title fs-5">Link check options</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6> <h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
<div class="form-check form-switch mb-4"> <div class="form-check form-switch mb-4">
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects" <input
id="LinkCheckFollowRedirectsSwitch"> id="LinkCheckFollowRedirectsSwitch"
v-model="followRedirects"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch"> <label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
<template v-if="followRedirects">Following HTTP redirects</template> <template v-if="followRedirects">Following HTTP redirects</template>
<template v-else>Not following HTTP redirects</template> <template v-else>Not following HTTP redirects</template>
@@ -259,8 +271,13 @@ export default {
<h6 class="mt-4">Automatic link checking</h6> <h6 class="mt-4">Automatic link checking</h6>
<div class="form-check form-switch mb-3"> <div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" v-model="autoScan" <input
id="LinkCheckAutoCheckSwitch"> id="LinkCheckAutoCheckSwitch"
v-model="autoScan"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="LinkCheckAutoCheckSwitch"> <label class="form-check-label" for="LinkCheckAutoCheckSwitch">
<template v-if="autoScan">Automatic link checking is enabled</template> <template v-if="autoScan">Automatic link checking is enabled</template>
<template v-else>Automatic link checking is disabled</template> <template v-else>Automatic link checking is disabled</template>
@@ -270,7 +287,6 @@ export default {
Only enable this if you understand the potential risks &amp; consequences. Only enable this if you understand the potential risks &amp; consequences.
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@@ -279,25 +295,39 @@ export default {
</div> </div>
</div> </div>
<div class="modal fade" id="AboutLinkCheckResults" tabindex="-1" aria-labelledby="AboutLinkCheckResultsLabel" <div
aria-hidden="true"> id="AboutLinkCheckResults"
class="modal fade"
tabindex="-1"
aria-labelledby="AboutLinkCheckResultsLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="AboutLinkCheckResultsLabel">About Link check</h1> <h1 id="AboutLinkCheckResultsLabel" class="modal-title fs-5">About Link check</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="accordion" id="LinkCheckAboutAccordion"> <div id="LinkCheckAboutAccordion" class="accordion">
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col1" aria-expanded="false" aria-controls="col1"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col1"
aria-expanded="false"
aria-controls="col1"
>
What is Link check? What is Link check?
</button> </button>
</h2> </h2>
<div id="col1" class="accordion-collapse collapse" <div
data-bs-parent="#LinkCheckAboutAccordion"> id="col1"
class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
Link check scans your message HTML and text for all unique links, images and linked Link check scans your message HTML and text for all unique links, images and linked
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
@@ -307,35 +337,52 @@ export default {
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col2" aria-expanded="false" aria-controls="col2"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col2"
aria-expanded="false"
aria-controls="col2"
>
What are "301" and "302" links? What are "301" and "302" links?
</button> </button>
</h2> </h2>
<div id="col2" class="accordion-collapse collapse" <div
data-bs-parent="#LinkCheckAboutAccordion"> id="col2"
class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
These are links that redirect you to another URL, for example newsletters These are links that redirect you to another URL, for example newsletters often
often use redirect links to track user clicks. use redirect links to track user clicks.
</p> </p>
<p> <p>
By default Link check will not follow these links, however you can turn this on By default Link check will not follow these links, however you can turn this on
via via the settings and Link check will "follow" those redirects.
the settings and Link check will "follow" those redirects.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col3" aria-expanded="false" aria-controls="col3"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col3"
aria-expanded="false"
aria-controls="col3"
>
Why are some links returning an error but work in my browser? Why are some links returning an error but work in my browser?
</button> </button>
</h2> </h2>
<div id="col3" class="accordion-collapse collapse" <div
data-bs-parent="#LinkCheckAboutAccordion"> id="col3"
class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p>This may be due to various reasons, for instance:</p> <p>This may be due to various reasons, for instance:</p>
<ul> <ul>
@@ -352,13 +399,22 @@ export default {
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col4" aria-expanded="false" aria-controls="col4"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col4"
aria-expanded="false"
aria-controls="col4"
>
What are the risks of running Link check automatically? What are the risks of running Link check automatically?
</button> </button>
</h2> </h2>
<div id="col4" class="accordion-collapse collapse" <div
data-bs-parent="#LinkCheckAboutAccordion"> id="col4"
class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
Depending on the type of messages you are testing, opening all links on all Depending on the type of messages you are testing, opening all links on all
@@ -382,7 +438,6 @@ export default {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

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

View File

@@ -1,84 +1,102 @@
<script> <script>
import commonMixins from '../../mixins/CommonMixins' import commonMixins from "../../mixins/CommonMixins";
import ICAL from "ical.js" import ICAL from "ical.js";
import dayjs from 'dayjs' import dayjs from "dayjs";
export default { export default {
props: {
message: Object,
attachments: Object
},
mixins: [commonMixins], mixins: [commonMixins],
props: {
message: {
type: Object,
required: true,
},
attachments: {
type: Object,
required: true,
},
},
data() { data() {
return { return {
ical: false ical: false,
} };
}, },
methods: { methods: {
openAttachment(part, e) { openAttachment(part, e) {
let filename = part.FileName const filename = part.FileName;
let contentType = part.ContentType const contentType = part.ContentType;
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID) const href = this.resolve("/api/v1/message/" + this.message.ID + "/part/" + part.PartID);
if (filename.match(/\.ics$/i) || contentType == 'text/calendar') { if (filename.match(/\.ics$/i) || contentType === "text/calendar") {
e.preventDefault() e.preventDefault();
this.get(href, null, (response) => { this.get(href, null, (response) => {
let comp = new ICAL.Component(ICAL.parse(response.data)) const comp = new ICAL.Component(ICAL.parse(response.data));
let vevent = comp.getFirstSubcomponent('vevent') const vevent = comp.getFirstSubcomponent("vevent");
if (!vevent) { if (!vevent) {
alert('Error parsing ICS file') alert("Error parsing ICS file");
return return;
} }
let event = new ICAL.Event(vevent) const event = new ICAL.Event(vevent);
let summary = {} const summary = {};
summary.link = href summary.link = href;
summary.status = vevent.getFirstPropertyValue('status') summary.status = vevent.getFirstPropertyValue("status");
summary.url = vevent.getFirstPropertyValue('url') summary.url = vevent.getFirstPropertyValue("url");
summary.summary = event.summary summary.summary = event.summary;
summary.description = event.description summary.description = event.description;
summary.location = event.location summary.location = event.location;
summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a') summary.start = dayjs(event.startDate).format("ddd, D MMM YYYY, h:mm a");
summary.end = dayjs(event.endDate).format('ddd, D MMM YYYY, h:mm a') summary.end = dayjs(event.endDate).format("ddd, D MMM YYYY, h:mm a");
summary.isRecurring = event.isRecurring() summary.isRecurring = event.isRecurring();
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, "") : false;
summary.attendees = [] summary.attendees = [];
event.attendees.forEach((a) => { event.attendees.forEach((a) => {
if (a.jCal[1].cn) { if (a.jCal[1].cn) {
summary.attendees.push(a.jCal[1].cn) summary.attendees.push(a.jCal[1].cn);
} }
}) });
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => { comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
summary.timezone = vtimezone.getFirstPropertyValue("tzid") summary.timezone = vtimezone.getFirstPropertyValue("tzid");
}) });
this.ical = summary this.ical = summary;
// display modal // display modal
this.modal('ICSView').show() this.modal("ICSView").show();
}) });
}
} }
}, },
} },
};
</script> </script>
<template> <template>
<div class="mt-4 border-top pt-4"> <div class="mt-4 border-top pt-4">
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)" <a
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px" v-for="part in attachments"
@click="openAttachment(part, $event)"> :key="part.PartID"
<img v-if="isImage(part)" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top" class="card attachment float-start me-3 mb-3"
alt=""> target="_blank"
<img v-else style="width: 180px"
@click="openAttachment(part, $event)"
>
<img
v-if="isImage(part)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
class="card-img-top"
alt=""
/>
<img
v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
class="card-img-top" alt=""> class="card-img-top"
<div class="icon" v-if="!isImage(part)"> alt=""
/>
<div v-if="!isImage(part)" class="icon">
<i class="bi" :class="attachmentIcon(part)"></i> <i class="bi" :class="attachmentIcon(part)"></i>
</div> </div>
<div class="card-body border-0"> <div class="card-body border-0">
@@ -87,16 +105,16 @@ export default {
<small>{{ getFileSize(part.Size) }}</small> <small>{{ getFileSize(part.Size) }}</small>
</p> </p>
<p class="card-text mb-0 small"> <p class="card-text mb-0 small">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }} {{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</p> </p>
</div> </div>
<div class="card-footer small border-0 text-center text-truncate"> <div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }} {{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</div> </div>
</a> </a>
</div> </div>
<div class="modal fade" id="ICSView" tabindex="-1" aria-hidden="true"> <div id="ICSView" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg"> <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -106,7 +124,7 @@ export default {
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body" v-if="ical"> <div v-if="ical" class="modal-body">
<table class="table"> <table class="table">
<tbody> <tbody>
<tr v-if="ical.summary"> <tr v-if="ical.summary">
@@ -134,7 +152,9 @@ export default {
</tr> </tr>
<tr v-if="ical.url"> <tr v-if="ical.url">
<th>URL</th> <th>URL</th>
<td><a :href="ical.url" target="_blank">{{ ical.url }}</a></td> <td>
<a :href="ical.url" target="_blank">{{ ical.url }}</a>
</td>
</tr> </tr>
<tr v-if="ical.organizer"> <tr v-if="ical.organizer">
<th>Organizer</th> <th>Organizer</th>
@@ -143,7 +163,7 @@ export default {
<tr v-if="ical.attendees.length"> <tr v-if="ical.attendees.length">
<th>Attendees</th> <th>Attendees</th>
<td> <td>
<span v-for="(a, i) in ical.attendees"> <span v-for="(a, i) in ical.attendees" :key="'attendee_' + i">
<template v-if="i > 0">,</template> <template v-if="i > 0">,</template>
{{ a }} {{ a }}
</span> </span>
@@ -154,12 +174,9 @@ export default {
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<a class="btn btn-primary" target="_blank" :href="ical.link"> <a class="btn btn-primary" target="_blank" :href="ical.link"> Download attachment </a>
Download attachment
</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,40 @@
<script>
import commonMixins from "../../mixins/CommonMixins";
export default {
mixins: [commonMixins],
props: {
message: {
type: Object,
required: true,
},
},
data() {
return {
headers: false,
};
},
mounted() {
const uri = this.resolve("/api/v1/message/" + this.message.ID + "/headers");
this.get(uri, false, (response) => {
this.headers = response.data;
});
},
};
</script>
<template>
<div v-if="headers" class="small">
<div v-for="(values, k) in headers" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
<div class="col-md-4 col-lg-3 col-xl-2 mb-2">
<b>{{ k }}</b>
</div>
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div>
</div>
</div>
</div>
</template>

View File

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

View File

@@ -1,19 +1,24 @@
<script> <script>
import AjaxLoader from '../AjaxLoader.vue' import AjaxLoader from "../AjaxLoader.vue";
import Tags from "bootstrap5-tags" import Tags from "bootstrap5-tags";
import commonMixins from '../../mixins/CommonMixins' import commonMixins from "../../mixins/CommonMixins";
import { mailbox } from '../../stores/mailbox' import { mailbox } from "../../stores/mailbox";
export default { export default {
props: {
message: Object,
},
components: { components: {
AjaxLoader, AjaxLoader,
}, },
emits: ['delete'], mixins: [commonMixins],
props: {
message: {
type: Object,
default: () => ({}),
},
},
emits: ["delete"],
data() { data() {
return { return {
@@ -21,64 +26,62 @@ export default {
deleteAfterRelease: false, deleteAfterRelease: false,
mailbox, mailbox,
allAddresses: [], allAddresses: [],
} };
}, },
mixins: [commonMixins],
mounted() { mounted() {
let a = [] const a = [];
for (let i in this.message.To) { for (const i in this.message.To) {
a.push(this.message.To[i].Address) a.push(this.message.To[i].Address);
} }
for (let i in this.message.Cc) { for (const i in this.message.Cc) {
a.push(this.message.Cc[i].Address) a.push(this.message.Cc[i].Address);
} }
for (let i in this.message.Bcc) { for (const i in this.message.Bcc) {
a.push(this.message.Bcc[i].Address) a.push(this.message.Bcc[i].Address);
} }
// include only unique email addresses, regardless of casing // include only unique email addresses, regardless of casing
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map(ad => [ad.toLowerCase(), ad])).values()])) this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map((ad) => [ad.toLowerCase(), ad])).values()]));
this.addresses = this.allAddresses this.addresses = this.allAddresses;
}, },
methods: { methods: {
// triggered manually after modal is shown // triggered manually after modal is shown
initTags() { initTags() {
Tags.init("select[multiple]") Tags.init("select[multiple]");
}, },
releaseMessage() { releaseMessage() {
// set timeout to allow for user clicking send before the tag filter has applied the tag // set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(() => { window.setTimeout(() => {
if (!this.addresses.length) { if (!this.addresses.length) {
return false return false;
} }
let data = { const data = {
To: this.addresses To: this.addresses,
} };
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => { this.post(this.resolve("/api/v1/message/" + this.message.ID + "/release"), data, (response) => {
this.modal("ReleaseModal").hide() this.modal("ReleaseModal").hide();
if (this.deleteAfterRelease) { if (this.deleteAfterRelease) {
this.$emit('delete') this.$emit("delete");
}
})
}, 100)
}
}
} }
});
}, 100);
},
},
};
</script> </script>
<template> <template>
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true"> <div id="ReleaseModal" class="modal fade" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl" v-if="message"> <div v-if="message" class="modal-dialog modal-xl">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1> <h1 id="AppInfoModalLabel" class="modal-title fs-5">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -86,32 +89,55 @@ export default {
<div class="row"> <div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">From</label> <label class="col-sm-2 col-form-label text-body-secondary">From</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text" <input
aria-label="From address" readonly class="form-control-plaintext" v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''"
:value="mailbox.uiConfig.MessageRelay.OverrideFrom"> type="text"
<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext" aria-label="From address"
:value="message.From ? message.From.Address : ''"> readonly
class="form-control-plaintext"
:value="mailbox.uiConfig.MessageRelay.OverrideFrom"
/>
<input
v-else
type="text"
aria-label="From address"
readonly
class="form-control-plaintext"
:value="message.From ? message.From.Address : ''"
/>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">Subject</label> <label class="col-sm-2 col-form-label text-body-secondary">Subject</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input type="text" aria-label="Subject" readonly class="form-control-plaintext" <input
:value="message.Subject"> type="text"
aria-label="Subject"
readonly
class="form-control-plaintext"
:value="message.Subject"
/>
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label> <label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true" <select
data-clear-end="true" data-allow-clear="true" v-model="addresses"
data-placeholder="Enter email addresses..." data-add-on-blur="true" class="form-select tag-selector"
multiple
data-allow-new="true"
data-clear-end="true"
data-allow-clear="true"
data-placeholder="Enter email addresses..."
data-add-on-blur="true"
data-badge-style="primary" data-badge-style="primary"
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$' data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
data-separator="|,|"> data-separator="|,|"
>
<option value="">Enter email addresses...</option> <option value="">Enter email addresses...</option>
<!-- you need at least one option with the placeholder --> <!-- you need at least one option with the placeholder -->
<option v-for="t in allAddresses" :value="t">{{ t }}</option> <option v-for="t in allAddresses" :key="'address+' + t" :value="t">{{ t }}</option>
</select> </select>
<div class="invalid-feedback">Invalid email address</div> <div class="invalid-feedback">Invalid email address</div>
</div> </div>
@@ -119,8 +145,12 @@ export default {
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-10 offset-sm-2"> <div class="col-sm-10 offset-sm-2">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" v-model="deleteAfterRelease" <input
id="DeleteAfterRelease"> id="DeleteAfterRelease"
v-model="deleteAfterRelease"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="DeleteAfterRelease"> <label class="form-check-label" for="DeleteAfterRelease">
Delete the message after release Delete the message after release
</label> </label>
@@ -145,7 +175,8 @@ export default {
</li> </li>
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text"> <li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
The <code>From</code> email address has been overridden by the relay configuration to The <code>From</code> email address has been overridden by the relay configuration to
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code>. <code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code
>.
</li> </li>
<li class="form-text"> <li class="form-text">
SMTP delivery failures will bounce back to SMTP delivery failures will bounce back to
@@ -155,14 +186,16 @@ export default {
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''"> <code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }} {{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
</code> </code>
<code v-else>{{ message.ReturnPath }}</code>. <code v-else>{{ message.ReturnPath }}</code
>.
</li> </li>
</ul> </ul>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length" <button type="button" class="btn btn-primary" :disabled="!addresses.length" @click="releaseMessage">
v-on:click="releaseMessage">Release</button> Release
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,165 @@
<script>
import AjaxLoader from "../AjaxLoader.vue";
import CommonMixins from "../../mixins/CommonMixins";
import { domToPng } from "modern-screenshot";
export default {
components: {
AjaxLoader,
},
mixins: [CommonMixins],
props: {
message: {
type: Object,
default: () => ({}),
},
},
data() {
return {
html: false,
loading: 0,
};
},
methods: {
initScreenshot() {
this.loading = 1;
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/im, "");
const proxy = this.resolve("/proxy");
// Outlook hacks - else screenshot returns blank image
h = h.replace(/<html [^>]+>/gim, "<html>"); // remove html attributes
h = h.replace(/<o:p><\/o:p>/gm, ""); // remove empty `<o:p></o:p>` tags
h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>`
h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>`
// update any inline `url(...)` absolute links
const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim;
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
if (typeof p2 === "string") {
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`;
}
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`;
});
// create temporary document to manipulate
const doc = document.implementation.createHTMLDocument();
doc.open();
doc.write(h);
doc.close();
// remove any <script> tags
const scripts = doc.getElementsByTagName("script");
for (const i of scripts) {
i.parentNode.removeChild(i);
}
// replace stylesheet links with proxy links
const stylesheets = doc.getElementsByTagName("link");
for (const i of stylesheets) {
const src = i.getAttribute("href");
if (
src &&
src.match(/^https?:\/\//i) &&
src.indexOf(window.location.origin + window.location.pathname) !== 0
) {
i.setAttribute("href", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
}
}
// replace images with proxy links
const images = doc.getElementsByTagName("img");
for (const i of images) {
const src = i.getAttribute("src");
if (
src &&
src.match(/^https?:\/\//i) &&
src.indexOf(window.location.origin + window.location.pathname) !== 0
) {
i.setAttribute("src", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
}
}
// replace background="" attributes with proxy links
const backgrounds = doc.querySelectorAll("[background]");
for (const i of backgrounds) {
const src = i.getAttribute("background");
if (
src &&
src.match(/^https?:\/\//i) &&
src.indexOf(window.location.origin + window.location.pathname) !== 0
) {
// replace with proxy link
i.setAttribute("background", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
}
}
// set html with manipulated document content
this.html = new XMLSerializer().serializeToString(doc);
},
// HTML decode function
decodeEntities(s) {
const e = document.createElement("div");
e.innerHTML = s;
const str = e.textContent;
e.textContent = "";
return str;
},
doScreenshot() {
let width = document.getElementById("message-view").getBoundingClientRect().width;
const prev = document.getElementById("preview-html");
if (prev && prev.getBoundingClientRect().width) {
width = prev.getBoundingClientRect().width;
}
if (width < 300) {
width = 300;
}
const i = document.getElementById("screenshot-html");
// set the iframe width
i.style.width = width + "px";
const body = i.contentWindow.document.querySelector("body");
// take screenshot of iframe
domToPng(body, {
backgroundColor: "#ffffff",
height: i.contentWindow.document.body.scrollHeight + 20,
width,
}).then((dataUrl) => {
const link = document.createElement("a");
link.download = this.message.ID + ".png";
link.href = dataUrl;
link.click();
this.loading = 0;
this.html = false;
});
},
},
};
</script>
<template>
<iframe
v-if="html"
id="screenshot-html"
:srcdoc="html"
frameborder="0"
style="position: absolute; margin-left: -100000px"
@load="doScreenshot"
>
</iframe>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -1,144 +0,0 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import CommonMixins from '../../mixins/CommonMixins'
import { domToPng } from 'modern-screenshot'
export default {
props: {
message: Object,
},
mixins: [CommonMixins],
components: {
AjaxLoader,
},
data() {
return {
html: false,
loading: 0
}
},
methods: {
initScreenshot() {
this.loading = 1
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/mi, '')
let proxy = this.resolve('/proxy')
// Outlook hacks - else screenshot returns blank image
h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes
h = h.replace(/<o:p><\/o:p>/mg, '') // remove empty `<o:p></o:p>` tags
h = h.replace(/<o:/mg, '<') // replace `<o:p>` tags with `<p>`
h = h.replace(/<\/o:/mg, '</') // replace `</o:p>` tags with `</p>`
// update any inline `url(...)` absolute links
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
if (typeof p2 === 'string') {
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
}
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
})
// create temporary document to manipulate
let doc = document.implementation.createHTMLDocument();
doc.open()
doc.write(h)
doc.close()
// remove any <script> tags
let scripts = doc.getElementsByTagName('script')
for (let i of scripts) {
i.parentNode.removeChild(i)
}
// replace stylesheet links with proxy links
let stylesheets = doc.getElementsByTagName('link')
for (let i of stylesheets) {
let src = i.getAttribute('href')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
// replace images with proxy links
let images = doc.getElementsByTagName('img')
for (let i of images) {
let src = i.getAttribute('src')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
// replace background="" attributes with proxy links
let backgrounds = doc.querySelectorAll("[background]")
for (let i of backgrounds) {
let src = i.getAttribute('background')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
// replace with proxy link
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
// set html with manipulated document content
this.html = new XMLSerializer().serializeToString(doc)
},
// HTML decode function
decodeEntities(s) {
let e = document.createElement('div')
e.innerHTML = s
let str = e.textContent
e.textContent = ''
return str
},
doScreenshot() {
let width = document.getElementById('message-view').getBoundingClientRect().width
let prev = document.getElementById('preview-html')
if (prev && prev.getBoundingClientRect().width) {
width = prev.getBoundingClientRect().width
}
if (width < 300) {
width = 300
}
const i = document.getElementById('screenshot-html')
// set the iframe width
i.style.width = width + 'px'
let body = i.contentWindow.document.querySelector('body')
// take screenshot of iframe
domToPng(body, {
backgroundColor: '#ffffff',
height: i.contentWindow.document.body.scrollHeight + 20,
width: width,
}).then(dataUrl => {
const link = document.createElement('a')
link.download = this.message.ID + '.png'
link.href = dataUrl
link.click()
this.loading = 0
this.html = false
})
}
}
}
</script>
<template>
<iframe v-if="html" :srcdoc="html" v-on:load="doScreenshot" frameborder="0" id="screenshot-html"
style="position: absolute; margin-left: -100000px;">
</iframe>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -1,52 +1,85 @@
<script> <script>
import { VcDonut } from 'vue-css-donut-chart' import { VcDonut } from "vue-css-donut-chart";
import axios from 'axios' import axios from "axios";
import commonMixins from '../../mixins/CommonMixins' import commonMixins from "../../mixins/CommonMixins";
export default { export default {
props: {
message: Object,
},
components: { components: {
VcDonut, VcDonut,
}, },
emits: ["setSpamScore", "setBadgeStyle"],
mixins: [commonMixins], mixins: [commonMixins],
props: {
message: {
type: Object,
default: () => ({}),
},
},
emits: ["setSpamScore", "setBadgeStyle"],
data() { data() {
return { return {
error: false, error: false,
check: false, check: false,
} };
}, },
mounted() { computed: {
this.doCheck() graphSections() {
const score = this.check.Score;
let p = Math.round((score / 5) * 100);
if (p > 100) {
p = 100;
} else if (p < 0) {
p = 0;
}
let c = "#ffc107";
if (this.check.IsSpam) {
c = "#dc3545";
}
return [
{
label: score + " / 5",
value: p,
color: c,
},
];
},
scoreColor() {
return this.graphSections[0].color;
},
}, },
watch: { watch: {
message: { message: {
handler() { handler() {
this.$emit('setSpamScore', false) this.$emit("setSpamScore", false);
this.doCheck() this.doCheck();
}, },
deep: true deep: true,
}, },
}, },
mounted() {
this.doCheck();
},
methods: { methods: {
doCheck() { doCheck() {
this.check = false this.check = false;
// ignore any error, do not show loader // ignore any error, do not show loader
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null) axios
.get(this.resolve("/api/v1/message/" + this.message.ID + "/sa-check"), null)
.then((result) => { .then((result) => {
this.check = result.data this.check = result.data;
this.error = false this.error = false;
this.setIcons() this.setIcons();
}) })
.catch((error) => { .catch((error) => {
// handle error // handle error
@@ -54,80 +87,50 @@ export default {
// The request was made and the server responded with a status code // The request was made and the server responded with a status code
// that falls out of the range of 2xx // that falls out of the range of 2xx
if (error.response.data.Error) { if (error.response.data.Error) {
this.error = error.response.data.Error this.error = error.response.data.Error;
} else { } else {
this.error = error.response.data this.error = error.response.data;
} }
} else if (error.request) { } else if (error.request) {
// The request was made but no response was received // The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js // http.ClientRequest in node.js
this.error = 'Error sending data to the server. Please try again.' this.error = "Error sending data to the server. Please try again.";
} else { } else {
// Something happened in setting up the request that triggered an Error // Something happened in setting up the request that triggered an Error
this.error = error.message this.error = error.message;
} }
}) });
}, },
badgeStyle(ignorePadding = false) { badgeStyle(ignorePadding = false) {
let badgeStyle = 'bg-success' let badgeStyle = "bg-success";
if (this.check.Error) { if (this.check.Error) {
badgeStyle = 'bg-warning text-primary' badgeStyle = "bg-warning text-primary";
} } else if (this.check.IsSpam) {
else if (this.check.IsSpam) { badgeStyle = "bg-danger";
badgeStyle = 'bg-danger'
} else if (this.check.Score >= 4) { } else if (this.check.Score >= 4) {
badgeStyle = 'bg-warning text-primary' badgeStyle = "bg-warning text-primary";
} }
if (!ignorePadding && String(this.check.Score).includes('.')) { if (!ignorePadding && String(this.check.Score).includes(".")) {
badgeStyle += " p-1" badgeStyle += " p-1";
} }
return badgeStyle return badgeStyle;
}, },
setIcons() { setIcons() {
let score = this.check.Score let score = this.check.Score;
if (this.check.Error && this.check.Error != '') { if (this.check.Error && this.check.Error !== "") {
score = '!' score = "!";
} }
let badgeStyle = this.badgeStyle() const badgeStyle = this.badgeStyle();
this.$emit('setBadgeStyle', badgeStyle) this.$emit("setBadgeStyle", badgeStyle);
this.$emit('setSpamScore', score) this.$emit("setSpamScore", score);
}, },
}, },
};
computed: {
graphSections() {
let score = this.check.Score
let p = Math.round(score / 5 * 100)
if (p > 100) {
p = 100
} else if (p < 0) {
p = 0
}
let c = '#ffc107'
if (this.check.IsSpam) {
c = '#dc3545'
}
return [
{
label: score + ' / 5',
value: p,
color: c
},
]
},
scoreColor() {
return this.graphSections[0].color
},
}
}
</script> </script>
<template> <template>
@@ -145,10 +148,10 @@ export default {
<template v-if="error || check.Error != ''"> <template v-if="error || check.Error != ''">
<p>Your message could not be checked</p> <p>Your message could not be checked</p>
<div class="alert alert-warning" v-if="error"> <div v-if="error" class="alert alert-warning">
{{ error }} {{ error }}
</div> </div>
<div class="alert alert-warning" v-else> <div v-else class="alert alert-warning">
There was an error contacting the configured SpamAssassin server: {{ check.Error }} There was an error contacting the configured SpamAssassin server: {{ check.Error }}
</div> </div>
</template> </template>
@@ -156,11 +159,18 @@ export default {
<template v-else-if="check"> <template v-else-if="check">
<div class="row w-100 mt-5"> <div class="row w-100 mt-5">
<div class="col-xl-5 mb-2"> <div class="col-xl-5 mb-2">
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20" <vc-donut
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754"> :sections="graphSections"
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings"> background="var(--bs-body-bg)"
{{ check.Score }} / 5 :size="230"
</h2> unit="px"
:thickness="20"
:total="100"
:start-angle="270"
:auto-adjust-text-size="true"
foreground="#198754"
>
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">{{ check.Score }} / 5</h2>
<div class="text-body mt-2"> <div class="text-body mt-2">
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span> <span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span> <span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
@@ -180,7 +190,7 @@ export default {
</div> </div>
</div> </div>
<div class="row w-100 py-2 border-bottom small" v-for="r in check.Rules"> <div v-for="r in check.Rules" :key="'rule_' + r.Name" class="row w-100 py-2 border-bottom small">
<div class="col-2 col-lg-1"> <div class="col-2 col-lg-1">
{{ r.Score }} {{ r.Score }}
</div> </div>
@@ -195,25 +205,39 @@ export default {
</div> </div>
</template> </template>
<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel" <div
aria-hidden="true"> id="AboutSpamAnalysis"
class="modal fade"
tabindex="-1"
aria-labelledby="AboutSpamAnalysisLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="AboutSpamAnalysisLabel">About Spam Analysis</h1> <h1 id="AboutSpamAnalysisLabel" class="modal-title fs-5">About Spam Analysis</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="accordion" id="SpamAnalysisAboutAccordion"> <div id="SpamAnalysisAboutAccordion" class="accordion">
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col1" aria-expanded="false" aria-controls="col1"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col1"
aria-expanded="false"
aria-controls="col1"
>
What is Spam Analysis? What is Spam Analysis?
</button> </button>
</h2> </h2>
<div id="col1" class="accordion-collapse collapse" <div
data-bs-parent="#SpamAnalysisAboutAccordion"> id="col1"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
Mailpit integrates with SpamAssassin to provide you with some insight into the Mailpit integrates with SpamAssassin to provide you with some insight into the
@@ -226,13 +250,22 @@ export default {
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col2" aria-expanded="false" aria-controls="col2"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col2"
aria-expanded="false"
aria-controls="col2"
>
How does the point system work? How does the point system work?
</button> </button>
</h2> </h2>
<div id="col2" class="accordion-collapse collapse" <div
data-bs-parent="#SpamAnalysisAboutAccordion"> id="col2"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
The default spam threshold is <code>5</code>, meaning any score lower than 5 is The default spam threshold is <code>5</code>, meaning any score lower than 5 is
@@ -248,18 +281,27 @@ export default {
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col3" aria-expanded="false" aria-controls="col3"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col3"
aria-expanded="false"
aria-controls="col3"
>
But I don't agree with the results... But I don't agree with the results...
</button> </button>
</h2> </h2>
<div id="col3" class="accordion-collapse collapse" <div
data-bs-parent="#SpamAnalysisAboutAccordion"> id="col3"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
Mailpit does not manipulate the results nor determine the "spamminess" of Mailpit does not manipulate the results nor determine the "spamminess" of your
your message. The result is what SpamAssassin returns, and it entirely message. The result is what SpamAssassin returns, and it entirely dependent on
dependent on how SpamAssassin is set up and optionally trained. how SpamAssassin is set up and optionally trained.
</p> </p>
<p> <p>
This tool is simply provided as an aid to assist you. If you are running your This tool is simply provided as an aid to assist you. If you are running your
@@ -271,20 +313,31 @@ export default {
</div> </div>
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button
data-bs-target="#col4" aria-expanded="false" aria-controls="col4"> class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#col4"
aria-expanded="false"
aria-controls="col4"
>
Where can I find more information about the triggered rules? Where can I find more information about the triggered rules?
</button> </button>
</h2> </h2>
<div id="col4" class="accordion-collapse collapse" <div
data-bs-parent="#SpamAnalysisAboutAccordion"> id="col4"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body"> <div class="accordion-body">
<p> <p>
Unfortunately the current <a href="https://spamassassin.apache.org/" Unfortunately the current
target="_blank">SpamAssassin website</a> no longer contains any relative <a href="https://spamassassin.apache.org/" target="_blank"
documentation about these, most likely because the rules come from different >SpamAssassin website</a
locations and change often. You will need to search the internet for these >
yourself. no longer contains any relative documentation about these, most likely because
the rules come from different locations and change often. You will need to
search the internet for these yourself.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,19 +1,18 @@
import axios from 'axios' import axios from "axios";
import dayjs from 'dayjs' import dayjs from "dayjs";
import ColorHash from 'color-hash' import ColorHash from "color-hash";
import { Modal, Offcanvas } from 'bootstrap' import { Modal, Offcanvas } from "bootstrap";
import { limitOptions } from "../stores/pagination"; import { limitOptions } from "../stores/pagination";
// BootstrapElement is used to return a fake Bootstrap element // BootstrapElement is used to return a fake Bootstrap element
// if the ID returns nothing to prevent errors. // if the ID returns nothing to prevent errors.
class BootstrapElement { class BootstrapElement {
constructor() { }
hide() {} hide() {}
show() {} show() {}
} }
// Set up the color hash generator lightness and hue to ensure darker colors // Set up the color hash generator lightness and hue to ensure darker colors
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] }) const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
/* Common mixin functions used in apps */ /* Common mixin functions used in apps */
export default { export default {
@@ -21,89 +20,89 @@ export default {
return { return {
loading: 0, loading: 0,
tagColorCache: {}, tagColorCache: {},
} };
}, },
methods: { methods: {
resolve(u) { resolve(u) {
return this.$router.resolve(u).href return this.$router.resolve(u).href;
}, },
searchURI(s) { searchURI(s) {
return this.resolve('/search') + '?q=' + encodeURIComponent(s) return this.resolve("/search") + "?q=" + encodeURIComponent(s);
}, },
getFileSize(bytes) { getFileSize(bytes) {
if (bytes == 0) { if (bytes === 0) {
return '0B' return "0B";
} }
var i = Math.floor(Math.log(bytes) / Math.log(1024)) const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i] return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
}, },
formatNumber(nr) { formatNumber(nr) {
return new Intl.NumberFormat().format(nr) return new Intl.NumberFormat().format(nr);
}, },
messageDate(d) { messageDate(d) {
return dayjs(d).format('ddd, D MMM YYYY, h:mm a') return dayjs(d).format("ddd, D MMM YYYY, h:mm a");
}, },
secondsToRelative(d) { secondsToRelative(d) {
return dayjs().subtract(d, 'seconds').fromNow() return dayjs().subtract(d, "seconds").fromNow();
}, },
tagEncodeURI(tag) { tagEncodeURI(tag) {
if (tag.match(/ /)) { if (tag.match(/ /)) {
tag = `"${tag}"` tag = `"${tag}"`;
} }
return encodeURIComponent(`tag:${tag}`) return encodeURIComponent(`tag:${tag}`);
}, },
getSearch() { getSearch() {
if (!window.location.search) { if (!window.location.search) {
return false return false;
} }
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search);
const q = urlParams.get('q')?.trim() const q = urlParams.get("q")?.trim();
if (!q) { if (!q) {
return false return false;
} }
return q return q;
}, },
getPaginationParams() { getPaginationParams() {
if (!window.location.search) { if (!window.location.search) {
return null return null;
} }
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search);
const start = parseInt(urlParams.get('start')?.trim(), 10) const start = parseInt(urlParams.get("start")?.trim(), 10);
const limit = parseInt(urlParams.get('limit')?.trim(), 10) const limit = parseInt(urlParams.get("limit")?.trim(), 10);
return { return {
start: Number.isInteger(start) && start >= 0 ? start : null, start: Number.isInteger(start) && start >= 0 ? start : null,
limit: limitOptions.includes(limit) ? limit : null, limit: limitOptions.includes(limit) ? limit : null,
} };
}, },
// generic modal get/set function // generic modal get/set function
modal(id) { modal(id) {
const e = document.getElementById(id) const e = document.getElementById(id);
if (e) { if (e) {
return Modal.getOrCreateInstance(e) return Modal.getOrCreateInstance(e);
} }
// in case there are open/close actions // in case there are open/close actions
return new BootstrapElement() return new BootstrapElement();
}, },
// close mobile navigation // close mobile navigation
hideNav() { hideNav() {
const e = document.getElementById('offcanvas') const e = document.getElementById("offcanvas");
if (e) { if (e) {
Offcanvas.getOrCreateInstance(e).hide() Offcanvas.getOrCreateInstance(e).hide();
} }
}, },
@@ -117,23 +116,24 @@ export default {
*/ */
get(url, values, callback, errorCallback, hideLoader) { get(url, values, callback, errorCallback, hideLoader) {
if (!hideLoader) { if (!hideLoader) {
this.loading++ this.loading++;
} }
axios.get(url, { params: values }) axios
.get(url, { params: values })
.then(callback) .then(callback)
.catch((err) => { .catch((err) => {
if (typeof errorCallback == 'function') { if (typeof errorCallback === "function") {
return errorCallback(err) return errorCallback(err);
} }
this.handleError(err) this.handleError(err);
}) })
.then(() => { .then(() => {
// always executed // always executed
if (!hideLoader && this.loading > 0) { if (!hideLoader && this.loading > 0) {
this.loading-- this.loading--;
} }
}) });
}, },
/** /**
@@ -144,16 +144,17 @@ export default {
* @params function callback function * @params function callback function
*/ */
post(url, data, callback) { post(url, data, callback) {
this.loading++ this.loading++;
axios.post(url, data) axios
.post(url, data)
.then(callback) .then(callback)
.catch(this.handleError) .catch(this.handleError)
.then(() => { .then(() => {
// always executed // always executed
if (this.loading > 0) { if (this.loading > 0) {
this.loading-- this.loading--;
} }
}) });
}, },
/** /**
@@ -164,16 +165,17 @@ export default {
* @params function callback function * @params function callback function
*/ */
delete(url, data, callback) { delete(url, data, callback) {
this.loading++ this.loading++;
axios.delete(url, { data: data }) axios
.delete(url, { data })
.then(callback) .then(callback)
.catch(this.handleError) .catch(this.handleError)
.then(() => { .then(() => {
// always executed // always executed
if (this.loading > 0) { if (this.loading > 0) {
this.loading-- this.loading--;
} }
}) });
}, },
/** /**
@@ -184,16 +186,17 @@ export default {
* @params function callback function * @params function callback function
*/ */
put(url, data, callback) { put(url, data, callback) {
this.loading++ this.loading++;
axios.put(url, data) axios
.put(url, data)
.then(callback) .then(callback)
.catch(this.handleError) .catch(this.handleError)
.then(() => { .then(() => {
// always executed // always executed
if (this.loading > 0) { if (this.loading > 0) {
this.loading-- this.loading--;
} }
}) });
}, },
// Ajax error message // Ajax error message
@@ -203,87 +206,87 @@ export default {
// The request was made and the server responded with a status code // The request was made and the server responded with a status code
// that falls out of the range of 2xx // that falls out of the range of 2xx
if (error.response.data.Error) { if (error.response.data.Error) {
alert(error.response.data.Error) alert(error.response.data.Error);
} else { } else {
alert(error.response.data) alert(error.response.data);
} }
} else if (error.request) { } else if (error.request) {
// The request was made but no response was received // The request was made but no response was received
alert('Error sending data to the server. Please try again.') alert("Error sending data to the server. Please try again.");
} else { } else {
// Something happened in setting up the request that triggered an Error // Something happened in setting up the request that triggered an Error
alert(error.message) alert(error.message);
} }
}, },
allAttachments(message) { allAttachments(message) {
let a = [] const a = [];
for (let i in message.Attachments) { for (const i in message.Attachments) {
a.push(message.Attachments[i]) a.push(message.Attachments[i]);
} }
for (let i in message.OtherParts) { for (const i in message.OtherParts) {
a.push(message.OtherParts[i]) a.push(message.OtherParts[i]);
} }
for (let i in message.Inline) { for (const i in message.Inline) {
a.push(message.Inline[i]) a.push(message.Inline[i]);
} }
return a.length ? a : false return a.length ? a : false;
}, },
isImage(a) { isImage(a) {
return a.ContentType.match(/^image\//) return a.ContentType.match(/^image\//);
}, },
attachmentIcon(a) { attachmentIcon(a) {
let ext = a.FileName.split('.').pop().toLowerCase() const ext = a.FileName.split(".").pop().toLowerCase();
if (a.ContentType.match(/^image\//)) { if (a.ContentType.match(/^image\//)) {
return 'bi-file-image-fill' return "bi-file-image-fill";
} }
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') { if (a.ContentType.match(/\/pdf$/) || ext === "pdf") {
return 'bi-file-pdf-fill' return "bi-file-pdf-fill";
} }
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) { if (["doc", "docx", "odt", "rtf"].includes(ext)) {
return 'bi-file-word-fill' return "bi-file-word-fill";
} }
if (['xls', 'xlsx', 'ods'].includes(ext)) { if (["xls", "xlsx", "ods"].includes(ext)) {
return 'bi-file-spreadsheet-fill' return "bi-file-spreadsheet-fill";
} }
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) { if (["ppt", "pptx", "key", "ppt", "odp"].includes(ext)) {
return 'bi-file-slides-fill' return "bi-file-slides-fill";
} }
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) { if (["zip", "tar", "rar", "bz2", "gz", "xz"].includes(ext)) {
return 'bi-file-zip-fill' return "bi-file-zip-fill";
} }
if (['ics'].includes(ext)) { if (["ics"].includes(ext)) {
return 'bi-calendar-event' return "bi-calendar-event";
} }
if (a.ContentType.match(/^audio\//)) { if (a.ContentType.match(/^audio\//)) {
return 'bi-file-music-fill' return "bi-file-music-fill";
} }
if (a.ContentType.match(/^video\//)) { if (a.ContentType.match(/^video\//)) {
return 'bi-file-play-fill' return "bi-file-play-fill";
} }
if (a.ContentType.match(/\/calendar$/)) { if (a.ContentType.match(/\/calendar$/)) {
return 'bi-file-check-fill' return "bi-file-check-fill";
} }
if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) { if (a.ContentType.match(/^text\//) || ["txt", "sh", "log"].includes(ext)) {
return 'bi-file-text-fill' return "bi-file-text-fill";
} }
return 'bi-file-arrow-down-fill' return "bi-file-arrow-down-fill";
}, },
// Returns a hex color based on a string. // Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing. // Values are stored in an array for faster lookup / processing.
colorHash(s) { colorHash(s) {
if (this.tagColorCache[s] != undefined) { if (this.tagColorCache[s] !== undefined) {
return this.tagColorCache[s] return this.tagColorCache[s];
} }
this.tagColorCache[s] = colorHash.hex(s) this.tagColorCache[s] = colorHash.hex(s);
return this.tagColorCache[s] return this.tagColorCache[s];
}, },
} },
} };

View File

@@ -1,6 +1,6 @@
import CommonMixins from './CommonMixins.js' import CommonMixins from "./CommonMixins.js";
import { mailbox } from '../stores/mailbox.js' import { mailbox } from "../stores/mailbox.js";
import { pagination } from '../stores/pagination.js' import { pagination } from "../stores/pagination.js";
export default { export default {
mixins: [CommonMixins], mixins: [CommonMixins],
@@ -10,88 +10,86 @@ export default {
apiURI: false, apiURI: false,
pagination, pagination,
mailbox, mailbox,
} };
}, },
watch: { watch: {
'mailbox.refresh': function (v) { "mailbox.refresh": function (v) {
if (v) { if (v) {
// trigger a refresh // trigger a refresh
this.loadMessages() this.loadMessages();
}
mailbox.refresh = false
} }
mailbox.refresh = false;
},
}, },
methods: { methods: {
reloadMailbox() { reloadMailbox() {
pagination.start = 0 pagination.start = 0;
this.loadMessages() this.loadMessages();
}, },
loadMessages() { loadMessages() {
if (!this.apiURI) { if (!this.apiURI) {
alert('apiURL not set!') alert("apiURL not set!");
return return;
} }
// auto-pagination changes the URL but should not fetch new messages // auto-pagination changes the URL but should not fetch new messages
// when viewing page > 0 and new messages are received (inbox only) // when viewing page > 0 and new messages are received (inbox only)
if (!mailbox.autoPaginating) { if (!mailbox.autoPaginating) {
mailbox.autoPaginating = true // reset mailbox.autoPaginating = true; // reset
return return;
} }
const params = {} const params = {};
mailbox.selected = [] mailbox.selected = [];
params['limit'] = pagination.limit params["limit"] = pagination.limit;
if (pagination.start > 0) { if (pagination.start > 0) {
params['start'] = pagination.start params["start"] = pagination.start;
} }
this.get(this.apiURI, params, (response) => { this.get(this.apiURI, params, (response) => {
mailbox.total = response.data.total // all messages mailbox.total = response.data.total; // all messages
mailbox.unread = response.data.unread // all unread messages mailbox.unread = response.data.unread; // all unread messages
mailbox.tags = response.data.tags // all tags mailbox.tags = response.data.tags; // all tags
mailbox.messages = response.data.messages // current messages mailbox.messages = response.data.messages; // current messages
mailbox.count = response.data.messages_count // total results for this mailbox/search mailbox.count = response.data.messages_count; // total results for this mailbox/search
mailbox.messages_unread = response.data.messages_unread // total unread results for this mailbox/search mailbox.messages_unread = response.data.messages_unread; // total unread results for this mailbox/search
// ensure the pagination remains consistent // ensure the pagination remains consistent
pagination.start = response.data.start pagination.start = response.data.start;
if (response.data.count == 0 && response.data.start > 0) { if (response.data.count === 0 && response.data.start > 0) {
pagination.start = 0 pagination.start = 0;
return this.loadMessages() return this.loadMessages();
} }
if (mailbox.lastMessage) { if (mailbox.lastMessage) {
window.setTimeout(() => { window.setTimeout(() => {
const m = document.getElementById(mailbox.lastMessage) const m = document.getElementById(mailbox.lastMessage);
if (m) { if (m) {
m.focus() m.focus();
// m.scrollIntoView({ behavior: 'smooth', block: 'center' }) // m.scrollIntoView({ behavior: 'smooth', block: 'center' })
m.scrollIntoView({ block: 'center' }) m.scrollIntoView({ block: "center" });
} else { } else {
const mp = document.getElementById('message-page') const mp = document.getElementById("message-page");
if (mp) { if (mp) {
mp.scrollTop = 0 mp.scrollTop = 0;
} }
} }
mailbox.lastMessage = false mailbox.lastMessage = false;
}, 50) }, 50);
} else if (!window.scrollInPlace) { } else if (!window.scrollInPlace) {
const mp = document.getElementById('message-page') const mp = document.getElementById("message-page");
if (mp) { if (mp) {
mp.scrollTop = 0 mp.scrollTop = 0;
} }
} }
window.scrollInPlace = false window.scrollInPlace = false;
}) });
}, },
} },
} };

View File

@@ -1,13 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from "vue-router";
import MailboxView from '../views/MailboxView.vue' import MailboxView from "../views/MailboxView.vue";
import MessageView from '../views/MessageView.vue' import MessageView from "../views/MessageView.vue";
import NotFoundView from '../views/NotFoundView.vue' import NotFoundView from "../views/NotFoundView.vue";
import SearchView from '../views/SearchView.vue' import SearchView from "../views/SearchView.vue";
let d = document.getElementById('app') const d = document.getElementById("app");
let webroot = '/' let webroot = "/";
if (d) { if (d) {
webroot = d.dataset.webroot webroot = d.dataset.webroot;
} }
// paths are relative to webroot // paths are relative to webroot
@@ -15,23 +15,23 @@ const router = createRouter({
history: createWebHistory(webroot), history: createWebHistory(webroot),
routes: [ routes: [
{ {
path: '/', path: "/",
component: MailboxView component: MailboxView,
}, },
{ {
path: '/search', path: "/search",
component: SearchView component: SearchView,
}, },
{ {
path: '/view/:id', path: "/view/:id",
component: MessageView component: MessageView,
}, },
{ {
path: '/:pathMatch(.*)*', path: "/:pathMatch(.*)*",
name: 'NotFound', name: "NotFound",
component: NotFoundView component: NotFoundView,
} },
] ],
}) });
export default router export default router;

View File

@@ -1,6 +1,6 @@
// State Management // State Management
import { reactive, watch } from 'vue' import { reactive, watch } from "vue";
// global mailbox info // global mailbox info
export const mailbox = reactive({ export const mailbox = reactive({
@@ -22,71 +22,73 @@ export const mailbox = reactive({
lastMessage: false, // return scrolling lastMessage: false, // return scrolling
// settings // settings
showTagColors: !localStorage.getItem('hideTagColors') == '1', showTagColors: !localStorage.getItem("hideTagColors"),
showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1', showHTMLCheck: !localStorage.getItem("hideHTMLCheck"),
showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1', showLinkCheck: !localStorage.getItem("hideLinkCheck"),
showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1', showSpamCheck: !localStorage.getItem("hideSpamCheck"),
timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: localStorage.getItem("timeZone")
}) ? localStorage.getItem("timeZone")
: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
watch( watch(
() => mailbox.count, () => mailbox.count,
(v) => { (v) => {
mailbox.selected = [] mailbox.selected = [];
} },
) );
watch( watch(
() => mailbox.showTagColors, () => mailbox.showTagColors,
(v) => { (v) => {
if (v) { if (v) {
localStorage.removeItem('hideTagColors') localStorage.removeItem("hideTagColors");
} else { } else {
localStorage.setItem('hideTagColors', '1') localStorage.setItem("hideTagColors", "1");
} }
} },
) );
watch( watch(
() => mailbox.showHTMLCheck, () => mailbox.showHTMLCheck,
(v) => { (v) => {
if (v) { if (v) {
localStorage.removeItem('hideHTMLCheck') localStorage.removeItem("hideHTMLCheck");
} else { } else {
localStorage.setItem('hideHTMLCheck', '1') localStorage.setItem("hideHTMLCheck", "1");
} }
} },
) );
watch( watch(
() => mailbox.showLinkCheck, () => mailbox.showLinkCheck,
(v) => { (v) => {
if (v) { if (v) {
localStorage.removeItem('hideLinkCheck') localStorage.removeItem("hideLinkCheck");
} else { } else {
localStorage.setItem('hideLinkCheck', '1') localStorage.setItem("hideLinkCheck", "1");
} }
} },
) );
watch( watch(
() => mailbox.showSpamCheck, () => mailbox.showSpamCheck,
(v) => { (v) => {
if (v) { if (v) {
localStorage.removeItem('hideSpamCheck') localStorage.removeItem("hideSpamCheck");
} else { } else {
localStorage.setItem('hideSpamCheck', '1') localStorage.setItem("hideSpamCheck", "1");
} }
} },
) );
watch( watch(
() => mailbox.timeZone, () => mailbox.timeZone,
(v) => { (v) => {
if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) { if (v === Intl.DateTimeFormat().resolvedOptions().timeZone) {
localStorage.removeItem('timeZone') localStorage.removeItem("timeZone");
} else { } else {
localStorage.setItem('timeZone', v) localStorage.setItem("timeZone", v);
} }
} },
) );

View File

@@ -1,4 +1,4 @@
import { reactive } from 'vue' import { reactive } from "vue";
export const pagination = reactive({ export const pagination = reactive({
start: 0, // pagination offset start: 0, // pagination offset
@@ -6,6 +6,6 @@ export const pagination = reactive({
defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit
total: 0, // total results of current view / filter total: 0, // total results of current view / filter
count: 0, // number of messages currently displayed count: 0, // number of messages currently displayed
}) });
export const limitOptions = [25, 50, 100, 200] export const limitOptions = [25, 50, 100, 200];

View File

@@ -1,24 +1,19 @@
<script> <script>
import AboutMailpit from '../components/AboutMailpit.vue' import About from "../components/AppAbout.vue";
import AjaxLoader from '../components/AjaxLoader.vue' import AjaxLoader from "../components/AjaxLoader.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import ListMessages from '../components/ListMessages.vue' import ListMessages from "../components/ListMessages.vue";
import MessagesMixins from '../mixins/MessagesMixins' import MessagesMixins from "../mixins/MessagesMixins";
import NavMailbox from '../components/NavMailbox.vue' import NavMailbox from "../components/NavMailbox.vue";
import NavTags from '../components/NavTags.vue' import NavTags from "../components/NavTags.vue";
import Pagination from '../components/Pagination.vue' import Pagination from "../components/NavPagination.vue";
import SearchForm from '../components/SearchForm.vue' import SearchForm from "../components/SearchForm.vue";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
import { pagination } from "../stores/pagination"; import { pagination } from "../stores/pagination";
export default { export default {
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: { components: {
AboutMailpit, About,
AjaxLoader, AjaxLoader,
ListMessages, ListMessages,
NavMailbox, NavMailbox,
@@ -27,111 +22,119 @@ export default {
SearchForm, SearchForm,
}, },
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() { data() {
return { return {
mailbox, mailbox,
delayedRefresh: false, delayedRefresh: false,
paginationDelayed: false, // for delayed pagination URL changes paginationDelayed: false, // for delayed pagination URL changes
} };
}, },
watch: { watch: {
$route(to, from) { $route(to, from) {
this.loadMailbox() this.loadMailbox();
} },
}, },
mounted() { mounted() {
mailbox.searching = false mailbox.searching = false;
this.apiURI = this.resolve(`/api/v1/messages`) this.apiURI = this.resolve(`/api/v1/messages`);
this.loadMailbox() this.loadMailbox();
// subscribe to events // subscribe to events
this.eventBus.on("new", this.handleWSNew) this.eventBus.on("new", this.handleWSNew);
this.eventBus.on("update", this.handleWSUpdate) this.eventBus.on("update", this.handleWSUpdate);
this.eventBus.on("delete", this.handleWSDelete) this.eventBus.on("delete", this.handleWSDelete);
this.eventBus.on("truncate", this.handleWSTruncate) this.eventBus.on("truncate", this.handleWSTruncate);
}, },
unmounted() { unmounted() {
// unsubscribe from events // unsubscribe from events
this.eventBus.off("new", this.handleWSNew) this.eventBus.off("new", this.handleWSNew);
this.eventBus.off("update", this.handleWSUpdate) this.eventBus.off("update", this.handleWSUpdate);
this.eventBus.off("delete", this.handleWSDelete) this.eventBus.off("delete", this.handleWSDelete);
this.eventBus.off("truncate", this.handleWSTruncate) this.eventBus.off("truncate", this.handleWSTruncate);
}, },
methods: { methods: {
loadMailbox() { loadMailbox() {
const paginationParams = this.getPaginationParams() const paginationParams = this.getPaginationParams();
if (paginationParams?.start) { if (paginationParams?.start) {
pagination.start = paginationParams.start pagination.start = paginationParams.start;
} else { } else {
pagination.start = 0 pagination.start = 0;
} }
if (paginationParams?.limit) { if (paginationParams?.limit) {
pagination.limit = paginationParams.limit pagination.limit = paginationParams.limit;
} }
this.loadMessages() this.loadMessages();
}, },
// This will only update the pagination offset at a maximum of 2x per second // This will only update the pagination offset at a maximum of 2x per second
// when viewing the inbox on > page 1, while receiving an influx of new messages. // when viewing the inbox on > page 1, while receiving an influx of new messages.
delayedPaginationUpdate() { delayedPaginationUpdate() {
if (this.paginationDelayed) { if (this.paginationDelayed) {
return return;
} }
this.paginationDelayed = true this.paginationDelayed = true;
window.setTimeout(() => { window.setTimeout(() => {
const path = this.$route.path const path = this.$route.path;
const p = { const p = {
...this.$route.query ...this.$route.query,
} };
if (pagination.start > 0) { if (pagination.start > 0) {
p.start = pagination.start.toString() p.start = pagination.start.toString();
} else { } else {
delete p.start delete p.start;
} }
if (pagination.limit != pagination.defaultLimit) { if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString() p.limit = pagination.limit.toString();
} else { } else {
delete p.limit delete p.limit;
} }
mailbox.autoPaginating = false // prevent reload of messages when URL changes mailbox.autoPaginating = false; // prevent reload of messages when URL changes
const params = new URLSearchParams(p) const params = new URLSearchParams(p);
this.$router.replace(path + '?' + params.toString()) this.$router.replace(path + "?" + params.toString());
this.paginationDelayed = false this.paginationDelayed = false;
}, 500) }, 500);
}, },
// handler for websocket new messages // handler for websocket new messages
handleWSNew(data) { handleWSNew(data) {
if (pagination.start < 1) { if (pagination.start < 1) {
// push results directly into first page // push results directly into first page
mailbox.messages.unshift(data) mailbox.messages.unshift(data);
if (mailbox.messages.length > pagination.limit) { if (mailbox.messages.length > pagination.limit) {
mailbox.messages.pop() mailbox.messages.pop();
} }
} else { } else {
// update pagination offset // update pagination offset
pagination.start++ pagination.start++;
// prevent "Too many calls to Location or History APIs within a short time frame" // prevent "Too many calls to Location or History APIs within a short time frame"
this.delayedPaginationUpdate() this.delayedPaginationUpdate();
} }
}, },
// handler for websocket message updates // handler for websocket message updates
handleWSUpdate(data) { handleWSUpdate(data) {
for (let x = 0; x < this.mailbox.messages.length; x++) { for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) { if (this.mailbox.messages[x].ID === data.ID) {
// update message // update message
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data } this.mailbox.messages[x] = {
return ...this.mailbox.messages[x],
...data,
};
return;
} }
} }
}, },
@@ -140,43 +143,43 @@ export default {
handleWSDelete(data) { handleWSDelete(data) {
let removed = 0; let removed = 0;
for (let x = 0; x < this.mailbox.messages.length; x++) { for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) { if (this.mailbox.messages[x].ID === data.ID) {
// remove message from the list // remove message from the list
this.mailbox.messages.splice(x, 1) this.mailbox.messages.splice(x, 1);
removed++ removed++;
continue continue;
} }
} }
if (!removed || this.delayedRefresh) { if (!removed || this.delayedRefresh) {
// nothing changed on this screen, or a refresh is queued, // nothing changed on this screen, or a refresh is queued,
// don't refresh // don't refresh
return return;
} }
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted // delayedRefresh prevents unnecessary reloads when multiple messages are deleted
this.delayedRefresh = true this.delayedRefresh = true;
window.setTimeout(() => { window.setTimeout(() => {
this.delayedRefresh = false this.delayedRefresh = false;
this.loadMessages() this.loadMessages();
}, 500) }, 500);
}, },
// handler for websocket message truncation // handler for websocket message truncation
handleWSTruncate() { handleWSTruncate() {
// all messages gone, reload // all messages gone, reload
this.loadMessages() this.loadMessages();
}, },
} },
} };
</script> </script>
<template> <template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none"> <div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
<div class="col-xl-2 col-md-3 col-auto pe-0"> <div class="col-xl-2 col-md-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox"> <RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox">
<img :src="resolve('/mailpit.svg')" alt="Mailpit"> <img :src="resolve('/mailpit.svg')" alt="Mailpit" />
<span class="ms-2 d-none d-sm-inline">Mailpit</span> <span class="ms-2 d-none d-sm-inline">Mailpit</span>
</RouterLink> </RouterLink>
</div> </div>
@@ -185,8 +188,13 @@ export default {
</div> </div>
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0"> <div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0">
<div class="float-start d-md-none"> <div class="float-start d-md-none">
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas" <button
data-bs-target="#offcanvas" aria-controls="offcanvas"> class="btn btn-outline-light me-2"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvas"
aria-controls="offcanvas"
>
<i class="bi bi-list"></i> <i class="bi bi-list"></i>
</button> </button>
</div> </div>
@@ -194,20 +202,30 @@ export default {
</div> </div>
</div> </div>
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas" <div
aria-labelledby="offcanvasLabel"> id="offcanvas"
class="offcanvas-md offcanvas-start d-md-none"
data-bs-scroll="true"
tabindex="-1"
aria-labelledby="offcanvasLabel"
>
<div class="offcanvas-header"> <div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5> <h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas" <button
aria-label="Close"></button> type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target="#offcanvas"
aria-label="Close"
></button>
</div> </div>
<div class="offcanvas-body pb-0"> <div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100"> <div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3"> <div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavMailbox @loadMessages="loadMessages" /> <NavMailbox @load-messages="loadMessages" />
<NavTags /> <NavTags />
</div> </div>
<AboutMailpit /> <About />
</div> </div>
</div> </div>
</div> </div>
@@ -215,20 +233,20 @@ export default {
<div class="row flex-fill" style="min-height: 0"> <div class="row flex-fill" style="min-height: 0">
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column"> <div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3"> <div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavMailbox @loadMessages="loadMessages" /> <NavMailbox @load-messages="loadMessages" />
<NavTags /> <NavTags />
</div> </div>
<AboutMailpit /> <About />
</div> </div>
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0"> <div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page"> <div id="message-page" class="mh-100" style="overflow-y: auto">
<ListMessages :loading-messages="loading" /> <ListMessages :loading-messages="loading" />
</div> </div>
</div> </div>
</div> </div>
<NavMailbox @loadMessages="loadMessages" modals /> <NavMailbox modals @load-messages="loadMessages" />
<AboutMailpit modals /> <About modals />
<AjaxLoader :loading="loading" /> <AjaxLoader :loading="loading" />
</template> </template>

View File

@@ -1,20 +1,15 @@
<script> <script>
import AboutMailpit from '../components/AboutMailpit.vue' import AboutMailpit from "../components/AppAbout.vue";
import AjaxLoader from '../components/AjaxLoader.vue' import AjaxLoader from "../components/AjaxLoader.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import Message from '../components/message/Message.vue' import Message from "../components/message/MessageItem.vue";
import Release from '../components/message/Release.vue' import Release from "../components/message/MessageRelease.vue";
import Screenshot from '../components/message/Screenshot.vue' import Screenshot from "../components/message/MessageScreenshot.vue";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
import { pagination } from '../stores/pagination' import { pagination } from "../stores/pagination";
import dayjs from 'dayjs' import dayjs from "dayjs";
export default { export default {
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: { components: {
AboutMailpit, AboutMailpit,
AjaxLoader, AjaxLoader,
@@ -23,6 +18,11 @@ export default {
Release, Release,
}, },
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() { data() {
return { return {
mailbox, mailbox,
@@ -36,203 +36,206 @@ export default {
liveLoaded: 0, // the number new messages prepended tp messageList liveLoaded: 0, // the number new messages prepended tp messageList
scrollLoading: false, scrollLoading: false,
canLoadMore: true, canLoadMore: true,
} };
},
watch: {
$route(to, from) {
this.loadMessage()
},
},
created() {
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
this.initLoadMoreAPIParams()
},
mounted() {
this.loadMessage()
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
if (!this.messagesList.length) {
this.loadMore()
}
this.refreshUI()
// subscribe to events
this.eventBus.on("new", this.handleWSNew)
this.eventBus.on("update", this.handleWSUpdate)
this.eventBus.on("delete", this.handleWSDelete)
this.eventBus.on("truncate", this.handleWSTruncate)
},
unmounted() {
// unsubscribe from events
this.eventBus.off("new", this.handleWSNew)
this.eventBus.off("update", this.handleWSUpdate)
this.eventBus.off("delete", this.handleWSDelete)
this.eventBus.off("truncate", this.handleWSTruncate)
}, },
computed: { computed: {
// get current message read status // get current message read status
isRead() { isRead() {
const l = this.messagesList.length const l = this.messagesList.length;
if (!this.message || !l) { if (!this.message || !l) {
return true return true;
} }
let id = false for (let x = 0; x < l; x++) {
for (x = 0; x < l; x++) { if (this.messagesList[x].ID === this.message.ID) {
if (this.messagesList[x].ID == this.message.ID) { return this.messagesList[x].Read;
return this.messagesList[x].Read
} }
} }
return true return true;
}, },
// get the previous message ID // get the previous message ID
previousID() { previousID() {
const l = this.messagesList.length const l = this.messagesList.length;
if (!this.message || !l) { if (!this.message || !l) {
return false return false;
} }
let id = false let id = false;
for (x = 0; x < l; x++) { for (let x = 0; x < l; x++) {
if (this.messagesList[x].ID == this.message.ID) { if (this.messagesList[x].ID === this.message.ID) {
return id return id;
} }
id = this.messagesList[x].ID id = this.messagesList[x].ID;
} }
return false return false;
}, },
// get the next message ID // get the next message ID
nextID() { nextID() {
const l = this.messagesList.length const l = this.messagesList.length;
if (!this.message || !l) { if (!this.message || !l) {
return false return false;
} }
let id = false let id = false;
for (x = l - 1; x > 0; x--) { for (let x = l - 1; x > 0; x--) {
if (this.messagesList[x].ID == this.message.ID) { if (this.messagesList[x].ID === this.message.ID) {
return id return id;
} }
id = this.messagesList[x].ID id = this.messagesList[x].ID;
} }
return id return id;
},
},
watch: {
$route(to, from) {
this.loadMessage();
},
},
created() {
const relativeTime = require("dayjs/plugin/relativeTime");
dayjs.extend(relativeTime);
this.initLoadMoreAPIParams();
},
mounted() {
this.loadMessage();
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages));
if (!this.messagesList.length) {
this.loadMore();
} }
this.refreshUI();
// subscribe to events
this.eventBus.on("new", this.handleWSNew);
this.eventBus.on("update", this.handleWSUpdate);
this.eventBus.on("delete", this.handleWSDelete);
this.eventBus.on("truncate", this.handleWSTruncate);
},
unmounted() {
// unsubscribe from events
this.eventBus.off("new", this.handleWSNew);
this.eventBus.off("update", this.handleWSUpdate);
this.eventBus.off("delete", this.handleWSDelete);
this.eventBus.off("truncate", this.handleWSTruncate);
}, },
methods: { methods: {
loadMessage() { loadMessage() {
this.message = false this.message = false;
const uri = this.resolve('/api/v1/message/' + this.$route.params.id) const uri = this.resolve("/api/v1/message/" + this.$route.params.id);
this.get(uri, false, (response) => { this.get(
this.errorMessage = false uri,
const d = response.data false,
(response) => {
this.errorMessage = false;
const d = response.data;
// update read status in case websockets is not working // update read status in case websockets is not working
this.handleWSUpdate({ 'ID': d.ID, Read: true }) this.handleWSUpdate({ ID: d.ID, Read: true });
// replace inline images embedded as inline attachments // replace inline images embedded as inline attachments
if (d.HTML && d.Inline) { if (d.HTML && d.Inline) {
for (let i in d.Inline) { for (const i in d.Inline) {
let a = d.Inline[i] const a = d.Inline[i];
if (a.ContentID != '') { if (a.ContentID !== "") {
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'), new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' "$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
) );
} }
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) { if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) {
// some old email clients use the filename // some old email clients use the filename
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'), new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' "$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
) );
} }
} }
} }
// replace inline images embedded as regular attachments // replace inline images embedded as regular attachments
if (d.HTML && d.Attachments) { if (d.HTML && d.Attachments) {
for (let i in d.Attachments) { for (const i in d.Attachments) {
let a = d.Attachments[i] const a = d.Attachments[i];
if (a.ContentID != '') { if (a.ContentID !== "") {
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'), new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' "$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
) );
} }
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) { if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) {
// some old email clients use the filename // some old email clients use the filename
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'), new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' "$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
) );
} }
} }
} }
this.message = d this.message = d;
this.$nextTick(() => { this.$nextTick(() => {
this.scrollSidebarToCurrent() this.scrollSidebarToCurrent();
}) });
}, },
(error) => { (error) => {
this.errorMessage = true this.errorMessage = true;
if (error.response && error.response.data) { if (error.response && error.response.data) {
if (error.response.data.Error) { if (error.response.data.Error) {
this.errorMessage = error.response.data.Error this.errorMessage = error.response.data.Error;
} else { } else {
this.errorMessage = error.response.data this.errorMessage = error.response.data;
} }
} else if (error.request) { } else if (error.request) {
// The request was made but no response was received // The request was made but no response was received
this.errorMessage = 'Error sending data to the server. Please refresh the page.' this.errorMessage = "Error sending data to the server. Please refresh the page.";
} else { } else {
// Something happened in setting up the request that triggered an Error // Something happened in setting up the request that triggered an Error
this.errorMessage = error.message this.errorMessage = error.message;
} }
}) },
);
}, },
// UI refresh ticker to adjust relative times // UI refresh ticker to adjust relative times
refreshUI() { refreshUI() {
window.setTimeout(() => { window.setTimeout(() => {
this.$forceUpdate() this.$forceUpdate();
this.refreshUI() this.refreshUI();
}, 30000) }, 30000);
}, },
// handler for websocket new messages // handler for websocket new messages
handleWSNew(data) { handleWSNew(data) {
// do not add when searching or >= 100 new messages have been received // do not add when searching or >= 100 new messages have been received
if (this.mailbox.searching || this.liveLoaded >= 100) { if (this.mailbox.searching || this.liveLoaded >= 100) {
return return;
} }
this.liveLoaded++ this.liveLoaded++;
this.messagesList.unshift(data) this.messagesList.unshift(data);
}, },
// handler for websocket message updates // handler for websocket message updates
handleWSUpdate(data) { handleWSUpdate(data) {
for (let x = 0; x < this.messagesList.length; x++) { for (let x = 0; x < this.messagesList.length; x++) {
if (this.messagesList[x].ID == data.ID) { if (this.messagesList[x].ID === data.ID) {
// update message // update message
this.messagesList[x] = { ...this.messagesList[x], ...data } this.messagesList[x] = { ...this.messagesList[x], ...data };
return return;
} }
} }
}, },
@@ -240,10 +243,10 @@ export default {
// handler for websocket message deletion // handler for websocket message deletion
handleWSDelete(data) { handleWSDelete(data) {
for (let x = 0; x < this.messagesList.length; x++) { for (let x = 0; x < this.messagesList.length; x++) {
if (this.messagesList[x].ID == data.ID) { if (this.messagesList[x].ID === data.ID) {
// remove message from the list // remove message from the list
this.messagesList.splice(x, 1) this.messagesList.splice(x, 1);
return return;
} }
} }
}, },
@@ -251,277 +254,299 @@ export default {
// handler for websocket message truncation // handler for websocket message truncation
handleWSTruncate() { handleWSTruncate() {
// all messages gone, go to inbox // all messages gone, go to inbox
this.$router.push('/') this.$router.push("/");
}, },
// return whether the sidebar is visible // return whether the sidebar is visible
sidebarVisible() { sidebarVisible() {
return this.$refs.MessageList.offsetParent != null return this.$refs.MessageList.offsetParent !== null;
}, },
// scroll sidenav to current message if found // scroll sidenav to current message if found
scrollSidebarToCurrent() { scrollSidebarToCurrent() {
const cont = document.getElementById('MessageList') const cont = document.getElementById("MessageList");
if (!cont) { if (!cont) {
return return;
} }
const c = cont.querySelector('.router-link-active') const c = cont.querySelector(".router-link-active");
if (c) { if (c) {
const outer = cont.getBoundingClientRect() const outer = cont.getBoundingClientRect();
const li = c.getBoundingClientRect() const li = c.getBoundingClientRect();
if (outer.top > li.top || outer.bottom < li.bottom) { if (outer.top > li.top || outer.bottom < li.bottom) {
c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }) c.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
} }
} }
}, },
scrollHandler(e) { scrollHandler(e) {
if (!this.canLoadMore || this.scrollLoading) { if (!this.canLoadMore || this.scrollLoading) {
return return;
} }
const { scrollTop, offsetHeight, scrollHeight } = e.target const { scrollTop, offsetHeight, scrollHeight } = e.target;
if ((scrollTop + offsetHeight + 150) >= scrollHeight) { if (scrollTop + offsetHeight + 150 >= scrollHeight) {
this.loadMore() this.loadMore();
} }
}, },
loadMore() { loadMore() {
if (this.messagesList.length) { if (this.messagesList.length) {
// get last created timestamp // get last created timestamp
const oldest = this.messagesList[this.messagesList.length - 1].Created const oldest = this.messagesList[this.messagesList.length - 1].Created;
// if set append `before=<ts>` // if set append `before=<ts>`
this.apiSideNavParams.set('before', oldest) this.apiSideNavParams.set("before", oldest);
} }
this.scrollLoading = true this.scrollLoading = true;
this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => { this.get(
this.apiSideNavURI,
this.apiSideNavParams,
(response) => {
if (response.data.messages.length) { if (response.data.messages.length) {
this.messagesList.push(...response.data.messages) this.messagesList.push(...response.data.messages);
} else { } else {
this.canLoadMore = false this.canLoadMore = false;
} }
this.$nextTick(() => { this.$nextTick(() => {
this.scrollLoading = false this.scrollLoading = false;
}) });
}, null, true) },
null,
true,
);
}, },
initLoadMoreAPIParams() { initLoadMoreAPIParams() {
let apiURI = this.resolve(`/api/v1/messages`) let apiURI = this.resolve(`/api/v1/messages`);
let p = {} const p = {};
if (mailbox.searching) { if (mailbox.searching) {
apiURI = this.resolve(`/api/v1/search`) apiURI = this.resolve(`/api/v1/search`);
p.query = mailbox.searching p.query = mailbox.searching;
} }
if (pagination.limit != pagination.defaultLimit) { if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString() p.limit = pagination.limit.toString();
} }
this.apiSideNavURI = apiURI this.apiSideNavURI = apiURI;
this.apiSideNavParams = new URLSearchParams(p) this.apiSideNavParams = new URLSearchParams(p);
}, },
getRelativeCreated(message) { getRelativeCreated(message) {
const d = new Date(message.Created) const d = new Date(message.Created);
return dayjs(d).fromNow() return dayjs(d).fromNow();
}, },
getPrimaryEmailTo(message) { getPrimaryEmailTo(message) {
for (let i in message.To) { if (message.To && message.To.length > 0) {
return message.To[i].Address return message.To[0].Address;
} }
return '[ Undisclosed recipients ]' return "[ Undisclosed recipients ]";
}, },
isActive(id) { isActive(id) {
return this.message.ID == id return this.message.ID === id;
}, },
toTagUrl(t) { toTagUrl(t) {
if (t.match(/ /)) { if (t.match(/ /)) {
t = `"${t}"` t = `"${t}"`;
} }
const p = { const p = {
q: 'tag:' + t q: "tag:" + t,
};
if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString();
} }
if (pagination.limit != pagination.defaultLimit) { const params = new URLSearchParams(p);
p.limit = pagination.limit.toString() return "/search?" + params.toString();
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
}, },
downloadMessageBody(str, ext) { downloadMessageBody(str, ext) {
const dl = document.createElement('a') const dl = document.createElement("a");
dl.href = "data:text/plain," + encodeURIComponent(str) dl.href = "data:text/plain," + encodeURIComponent(str);
dl.target = '_blank' dl.target = "_blank";
dl.download = this.message.ID + '.' + ext dl.download = this.message.ID + "." + ext;
dl.click() dl.click();
}, },
screenshotMessageHTML() { screenshotMessageHTML() {
this.$refs.ScreenshotRef.initScreenshot() this.$refs.ScreenshotRef.initScreenshot();
}, },
// toggle current message read status // toggle current message read status
toggleRead() { toggleRead() {
if (!this.message) { if (!this.message) {
return false return false;
} }
const read = !this.isRead const read = !this.isRead;
const ids = [this.message.ID] const ids = [this.message.ID];
const uri = this.resolve('/api/v1/messages') const uri = this.resolve("/api/v1/messages");
this.put(uri, { 'Read': read, 'IDs': ids }, () => { this.put(uri, { Read: read, IDs: ids }, () => {
if (!this.sidebarVisible()) { if (!this.sidebarVisible()) {
return this.goBack() return this.goBack();
} }
// manually update read status in case websockets is not working // manually update read status in case websockets is not working
this.handleWSUpdate({ 'ID': this.message.ID, Read: read }) this.handleWSUpdate({ ID: this.message.ID, Read: read });
}) });
}, },
deleteMessage() { deleteMessage() {
const ids = [this.message.ID] const ids = [this.message.ID];
const uri = this.resolve('/api/v1/messages') const uri = this.resolve("/api/v1/messages");
// calculate next ID before deletion to prevent WS race // calculate next ID before deletion to prevent WS race
const goToID = this.nextID ? this.nextID : this.previousID const goToID = this.nextID ? this.nextID : this.previousID;
this.delete(uri, { 'IDs': ids }, () => { this.delete(uri, { IDs: ids }, () => {
if (!this.sidebarVisible()) { if (!this.sidebarVisible()) {
return this.goBack() return this.goBack();
} }
if (goToID) { if (goToID) {
return this.$router.push('/view/' + goToID) return this.$router.push("/view/" + goToID);
} }
return this.goBack() return this.goBack();
}) });
}, },
// return to mailbox or search based on origin // return to mailbox or search based on origin
goBack() { goBack() {
mailbox.lastMessage = this.$route.params.id mailbox.lastMessage = this.$route.params.id;
if (mailbox.searching) { if (mailbox.searching) {
const p = { const p = {
q: mailbox.searching q: mailbox.searching,
} };
if (pagination.start > 0) { if (pagination.start > 0) {
p.start = pagination.start.toString() p.start = pagination.start.toString();
} }
if (pagination.limit != pagination.defaultLimit) { if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString() p.limit = pagination.limit.toString();
} }
this.$router.push('/search?' + new URLSearchParams(p).toString()) this.$router.push("/search?" + new URLSearchParams(p).toString());
} else { } else {
const p = {} const p = {};
if (pagination.start > 0) { if (pagination.start > 0) {
p.start = pagination.start.toString() p.start = pagination.start.toString();
} }
if (pagination.limit != pagination.defaultLimit) { if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString() p.limit = pagination.limit.toString();
} }
this.$router.push('/?' + new URLSearchParams(p).toString()) this.$router.push("/?" + new URLSearchParams(p).toString());
} }
}, },
reloadWindow() { reloadWindow() {
location.reload() location.reload();
}, },
initReleaseModal() { initReleaseModal() {
this.modal('ReleaseModal').show() this.modal("ReleaseModal").show();
window.setTimeout(() => { window.setTimeout(() => {
// delay to allow elements to load / focus // delay to allow elements to load / focus
this.$refs.ReleaseRef.initTags() this.$refs.ReleaseRef.initTags();
document.querySelector('#ReleaseModal input[role="combobox"]').focus() document.querySelector('#ReleaseModal input[role="combobox"]').focus();
}, 500) }, 500);
}, },
} },
} };
</script> </script>
<template> <template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none"> <div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
<div class="d-none d-xl-block col-xl-3 col-auto pe-0"> <div class="d-none d-xl-block col-xl-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0"> <RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
<img :src="resolve('/mailpit.svg')" alt="Mailpit"> <img :src="resolve('/mailpit.svg')" alt="Mailpit" />
<span class="ms-2 d-none d-sm-inline">Mailpit</span> <span class="ms-2 d-none d-sm-inline">Mailpit</span>
</RouterLink> </RouterLink>
</div> </div>
<div class="col col-xl-5" v-if="!errorMessage"> <div v-if="!errorMessage" class="col col-xl-5">
<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages"> <button class="btn btn-outline-light me-3 d-xl-none" title="Return to messages" @click="goBack()">
<i class="bi bi-arrow-return-left"></i> <i class="bi bi-arrow-return-left"></i>
<span class="ms-2 d-none d-lg-inline">Back</span> <span class="ms-2 d-none d-lg-inline">Back</span>
</button> </button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()"> <button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" @click="toggleRead()">
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i> <i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span> <span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
</button> </button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message" <button
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled" v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
v-on:click="initReleaseModal()"> class="btn btn-outline-light me-1 me-sm-2"
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span> title="Release message"
@click="initReleaseModal()"
>
<i class="bi bi-send"></i>
<span class="d-none d-md-inline">Release</span>
</button> </button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()"> <button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" @click="deleteMessage()">
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span> <i class="bi bi-trash-fill"></i>
<span class="d-none d-md-inline">Delete</span>
</button> </button>
</div> </div>
<div class="col-auto col-lg-4 col-xl-4 text-end" v-if="!errorMessage"> <div v-if="!errorMessage" class="col-auto col-lg-4 col-xl-4 text-end">
<div class="dropdown d-inline-block" id="DownloadBtn"> <div id="DownloadBtn" class="dropdown d-inline-block">
<button type="button" class="btn btn-outline-light dropdown-toggle" data-bs-toggle="dropdown" <button
aria-expanded="false"> type="button"
class="btn btn-outline-light dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="bi bi-file-arrow-down-fill"></i> <i class="bi bi-file-arrow-down-fill"></i>
<span class="d-none d-md-inline ms-1">Download</span> <span class="d-none d-md-inline ms-1">Download</span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<a :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" class="dropdown-item" <a
title="Message source including headers, body and attachments"> :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')"
class="dropdown-item"
title="Message source including headers, body and attachments"
>
Raw message Raw message
</a> </a>
</li> </li>
<li v-if="message.HTML"> <li v-if="message.HTML">
<button v-on:click="downloadMessageBody(message.HTML, 'html')" class="dropdown-item"> <button class="dropdown-item" @click="downloadMessageBody(message.HTML, 'html')">
HTML body HTML body
</button> </button>
</li> </li>
<li v-if="message.HTML"> <li v-if="message.HTML">
<button class="dropdown-item" @click="screenshotMessageHTML()"> <button class="dropdown-item" @click="screenshotMessageHTML()">HTML screenshot</button>
HTML screenshot
</button>
</li> </li>
<li v-if="message.Text"> <li v-if="message.Text">
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item"> <button class="dropdown-item" @click="downloadMessageBody(message.Text, 'txt')">
Text body Text body
</button> </button>
</li> </li>
<template v-if="message.Attachments && message.Attachments.length"> <template v-if="message.Attachments && message.Attachments.length">
<li> <li>
<hr class="dropdown-divider"> <hr class="dropdown-divider" />
</li> </li>
<li> <li>
<h6 class="dropdown-header"> <h6 class="dropdown-header">Attachments</h6>
Attachments
</h6>
</li> </li>
<li v-for="part in message.Attachments"> <li v-for="part in message.Attachments" :key="part.PartID">
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID" <RouterLink
class="row m-0 dropdown-item d-flex" target="_blank" :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px"> class="row m-0 dropdown-item d-flex"
target="_blank"
:title="part.FileName !== '' ? part.FileName : '[ unknown ]'"
style="min-width: 350px"
>
<div class="col-auto p-0 pe-1"> <div class="col-auto p-0 pe-1">
<i class="bi" :class="attachmentIcon(part)"></i> <i class="bi" :class="attachmentIcon(part)"></i>
</div> </div>
<div class="col text-truncate p-0 pe-1"> <div class="col text-truncate p-0 pe-1">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }} {{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
</div> </div>
<div class="col-auto text-muted small p-0"> <div class="col-auto text-muted small p-0">
{{ getFileSize(part.Size) }} {{ getFileSize(part.Size) }}
@@ -531,22 +556,24 @@ export default {
</template> </template>
<template v-if="message.Inline && message.Inline.length"> <template v-if="message.Inline && message.Inline.length">
<li> <li>
<hr class="dropdown-divider"> <hr class="dropdown-divider" />
</li> </li>
<li> <li>
<h6 class="dropdown-header"> <h6 class="dropdown-header">Inline image<span v-if="message.Inline.length > 1">s</span></h6>
Inline image<span v-if="message.Inline.length > 1">s</span>
</h6>
</li> </li>
<li v-for="part in message.Inline"> <li v-for="part in message.Inline" :key="part.PartID">
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID" <RouterLink
class="row m-0 dropdown-item d-flex" target="_blank" :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px"> class="row m-0 dropdown-item d-flex"
target="_blank"
:title="part.FileName !== '' ? part.FileName : '[ unknown ]'"
style="min-width: 350px"
>
<div class="col-auto p-0 pe-1"> <div class="col-auto p-0 pe-1">
<i class="bi" :class="attachmentIcon(part)"></i> <i class="bi" :class="attachmentIcon(part)"></i>
</div> </div>
<div class="col text-truncate p-0 pe-1"> <div class="col text-truncate p-0 pe-1">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }} {{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
</div> </div>
<div class="col-auto text-muted small p-0"> <div class="col-auto text-muted small p-0">
{{ getFileSize(part.Size) }} {{ getFileSize(part.Size) }}
@@ -557,8 +584,12 @@ export default {
</ul> </ul>
</div> </div>
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1" <RouterLink
:class="previousID ? '' : 'disabled'" title="View previous message"> :to="'/view/' + previousID"
class="btn btn-outline-light ms-1 ms-sm-2 me-1"
:class="previousID ? '' : 'disabled'"
title="View previous message"
>
<i class="bi bi-caret-left-fill"></i> <i class="bi bi-caret-left-fill"></i>
</RouterLink> </RouterLink>
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'"> <RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
@@ -569,67 +600,87 @@ export default {
<div class="row flex-fill" style="min-height: 0"> <div class="row flex-fill" style="min-height: 0">
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column"> <div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label"> <div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
<div class="text-truncate fw-normal" style="line-height: 1rem"> <div class="text-truncate fw-normal" style="line-height: 1rem">
{{ mailbox.uiConfig.Label }} {{ mailbox.uiConfig.Label }}
</div> </div>
</div> </div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''"> <div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="goBack()" class="list-group-item list-group-item-action"> <button class="list-group-item list-group-item-action" @click="goBack()">
<i class="bi bi-arrow-return-left me-1"></i> <i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1"> <span class="ms-1">
Return to Return to
<template v-if="mailbox.searching">search</template> <template v-if="mailbox.searching">search</template>
<template v-else>inbox</template> <template v-else>inbox</template>
</span> </span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages" <span
v-if="mailbox.unread && !errorMessage"> v-if="mailbox.unread && !errorMessage"
class="badge rounded-pill ms-1 float-end text-bg-secondary"
title="Unread messages"
>
{{ formatNumber(mailbox.unread) }} {{ formatNumber(mailbox.unread) }}
</span> </span>
</button> </button>
</div> </div>
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList" <div
@scroll="scrollHandler"> id="MessageList"
ref="MessageList"
class="flex-grow-1 overflow-y-auto px-1 me-n1"
@scroll="scrollHandler"
>
<button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()"> <button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()">
Reload to see newer messages Reload to see newer messages
</button> </button>
<template v-if="messagesList && messagesList.length"> <template v-if="messagesList && messagesList.length">
<div class="list-group"> <div class="list-group">
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID" <RouterLink
:id="message.ID" v-for="summary in messagesList"
:id="summary.ID"
:key="'summary_' + summary.ID"
:to="'/view/' + summary.ID"
class="row gx-1 message d-flex small list-group-item list-group-item-action message" class="row gx-1 message d-flex small list-group-item list-group-item-action message"
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''"> :class="[summary.Read ? 'read' : '', isActive(summary.ID) ? 'active' : '']"
>
<div class="col overflow-x-hidden"> <div class="col overflow-x-hidden">
<div class="text-truncate privacy small"> <div class="text-truncate privacy small">
<strong v-if="message.From" :title="'From: ' + message.From.Address"> <strong v-if="summary.From" :title="'From: ' + summary.From.Address">
{{ message.From.Name ? message.From.Name : message.From.Address }} {{ summary.From.Name ? summary.From.Name : summary.From.Address }}
</strong> </strong>
</div> </div>
</div> </div>
<div class="col-auto small"> <div class="col-auto small">
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i> <i v-if="summary.Attachments" class="bi bi-paperclip h6"></i>
{{ getRelativeCreated(message) }} {{ getRelativeCreated(summary) }}
</div> </div>
<div class="col-12 overflow-x-hidden"> <div class="col-12 overflow-x-hidden">
<div class="text-truncate privacy small"> <div class="text-truncate privacy small">
To: {{ getPrimaryEmailTo(message) }} To: {{ getPrimaryEmailTo(summary) }}
<span v-if="message.To && message.To.length > 1"> <span v-if="summary.To && summary.To.length > 1">
[+{{ message.To.length - 1 }}] [+{{ summary.To.length - 1 }}]
</span> </span>
</div> </div>
</div> </div>
<div class="col-12 overflow-x-hidden mt-1"> <div class="col-12 overflow-x-hidden mt-1">
<div class="text-truncates small"> <div class="text-truncates small">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b> <b>{{ summary.Subject !== "" ? summary.Subject : "[ no subject ]" }}</b>
</div> </div>
</div> </div>
<div v-if="message.Tags.length" class="col-12"> <div v-if="summary.Tags.length" class="col-12">
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)" <RouterLink
v-on:click="pagination.start = 0" v-for="t in summary.Tags"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }" :key="t"
:title="'Filter messages tagged with ' + t"> class="badge me-1"
:to="toTagUrl(t)"
:style="
mailbox.showTagColors
? { backgroundColor: colorHash(t) }
: { backgroundColor: '#6c757d' }
"
:title="'Filter messages tagged with ' + t"
@click="pagination.start = 0"
>
{{ t }} {{ t }}
</RouterLink> </RouterLink>
</div> </div>
@@ -642,7 +693,7 @@ export default {
</div> </div>
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0"> <div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page"> <div id="message-page" class="mh-100" style="overflow-y: auto">
<template v-if="errorMessage"> <template v-if="errorMessage">
<h3 class="text-center my-3"> <h3 class="text-center my-3">
{{ errorMessage }} {{ errorMessage }}
@@ -655,7 +706,11 @@ export default {
<AboutMailpit modals /> <AboutMailpit modals />
<AjaxLoader :loading="loading" /> <AjaxLoader :loading="loading" />
<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message" <Release
@delete="deleteMessage" /> v-if="mailbox.uiConfig.MessageRelay && message"
ref="ReleaseRef"
:message="message"
@delete="deleteMessage"
/>
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" /> <Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
</template> </template>

View File

@@ -1,21 +1,21 @@
<script> <script>
import AboutMailpit from '../components/AboutMailpit.vue' import About from "../components/AppAbout.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
export default { export default {
mixins: [CommonMixins],
components: { components: {
AboutMailpit, About,
}, },
}
mixins: [CommonMixins],
};
</script> </script>
<template> <template>
<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white"> <div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white">
<div class="d-block text-center"> <div class="d-block text-center">
<RouterLink to="/" class="text-white"> <RouterLink to="/" class="text-white">
<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width:80%; width: 100px;"> <img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width: 80%; width: 100px" />
<p class="h2 my-3">Page not found</p> <p class="h2 my-3">Page not found</p>
<p>Click here to continue</p> <p>Click here to continue</p>
@@ -23,7 +23,7 @@ export default {
</div> </div>
<div class="d-none"> <div class="d-none">
<AboutMailpit /> <About />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,24 +1,19 @@
<script> <script>
import AboutMailpit from '../components/AboutMailpit.vue' import About from "../components/AppAbout.vue";
import AjaxLoader from '../components/AjaxLoader.vue' import AjaxLoader from "../components/AjaxLoader.vue";
import CommonMixins from '../mixins/CommonMixins' import CommonMixins from "../mixins/CommonMixins";
import ListMessages from '../components/ListMessages.vue' import ListMessages from "../components/ListMessages.vue";
import MessagesMixins from '../mixins/MessagesMixins' import MessagesMixins from "../mixins/MessagesMixins";
import NavSearch from '../components/NavSearch.vue' import NavSearch from "../components/NavSearch.vue";
import NavTags from '../components/NavTags.vue' import NavTags from "../components/NavTags.vue";
import Pagination from '../components/Pagination.vue' import Pagination from "../components/NavPagination.vue";
import SearchForm from '../components/SearchForm.vue' import SearchForm from "../components/SearchForm.vue";
import { mailbox } from '../stores/mailbox' import { mailbox } from "../stores/mailbox";
import { pagination } from '../stores/pagination' import { pagination } from "../stores/pagination";
export default { export default {
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: { components: {
AboutMailpit, About,
AjaxLoader, AjaxLoader,
ListMessages, ListMessages,
NavSearch, NavSearch,
@@ -27,63 +22,68 @@ export default {
SearchForm, SearchForm,
}, },
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() { data() {
return { return {
mailbox, mailbox,
pagination, pagination,
delayedRefresh: false, delayedRefresh: false,
} };
}, },
watch: { watch: {
$route(to, from) { $route(to, from) {
this.doSearch() this.doSearch();
} },
}, },
mounted() { mounted() {
mailbox.searching = this.getSearch() mailbox.searching = this.getSearch();
this.doSearch() this.doSearch();
// subscribe to events // subscribe to events
this.eventBus.on("update", this.handleWSUpdate) this.eventBus.on("update", this.handleWSUpdate);
this.eventBus.on("delete", this.handleWSDelete) this.eventBus.on("delete", this.handleWSDelete);
this.eventBus.on("truncate", this.handleWSTruncate) this.eventBus.on("truncate", this.handleWSTruncate);
}, },
unmounted() { unmounted() {
// unsubscribe from events // unsubscribe from events
this.eventBus.off("update", this.handleWSUpdate) this.eventBus.off("update", this.handleWSUpdate);
this.eventBus.off("delete", this.handleWSDelete) this.eventBus.off("delete", this.handleWSDelete);
this.eventBus.off("truncate", this.handleWSTruncate) this.eventBus.off("truncate", this.handleWSTruncate);
}, },
methods: { methods: {
doSearch() { doSearch() {
const s = this.getSearch() const s = this.getSearch();
if (!s) { if (!s) {
mailbox.searching = false mailbox.searching = false;
this.$router.push('/') this.$router.push("/");
return return;
} }
mailbox.searching = s mailbox.searching = s;
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s) this.apiURI = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) { if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone) this.apiURI += "&tz=" + encodeURIComponent(mailbox.timeZone);
} }
this.loadMessages() this.loadMessages();
}, },
// handler for websocket message updates // handler for websocket message updates
handleWSUpdate(data) { handleWSUpdate(data) {
for (let x = 0; x < this.mailbox.messages.length; x++) { for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) { if (this.mailbox.messages[x].ID === data.ID) {
// update message // update message
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data } this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data };
return return;
} }
} }
}, },
@@ -92,52 +92,57 @@ export default {
handleWSDelete(data) { handleWSDelete(data) {
let removed = 0; let removed = 0;
for (let x = 0; x < this.mailbox.messages.length; x++) { for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) { if (this.mailbox.messages[x].ID === data.ID) {
// remove message from the list // remove message from the list
this.mailbox.messages.splice(x, 1) this.mailbox.messages.splice(x, 1);
removed++ removed++;
continue continue;
} }
} }
if (!removed || this.delayedRefresh) { if (!removed || this.delayedRefresh) {
// nothing changed on this screen, or a refresh is queued, don't refresh // nothing changed on this screen, or a refresh is queued, don't refresh
return return;
} }
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted // delayedRefresh prevents unnecessary reloads when multiple messages are deleted
this.delayedRefresh = true this.delayedRefresh = true;
window.setTimeout(() => { window.setTimeout(() => {
this.delayedRefresh = false this.delayedRefresh = false;
this.loadMessages() this.loadMessages();
}, 500) }, 500);
}, },
// handler for websocket message truncation // handler for websocket message truncation
handleWSTruncate() { handleWSTruncate() {
// all messages deleted, go back to inbox // all messages deleted, go back to inbox
this.$router.push('/') this.$router.push("/");
}, },
} },
} };
</script> </script>
<template> <template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none"> <div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
<div class="col-xl-2 col-md-3 col-auto pe-0"> <div class="col-xl-2 col-md-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0"> <RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
<img :src="resolve('/mailpit.svg')" alt="Mailpit"> <img :src="resolve('/mailpit.svg')" alt="Mailpit" />
<span class="ms-2 d-none d-sm-inline">Mailpit</span> <span class="ms-2 d-none d-sm-inline">Mailpit</span>
</RouterLink> </RouterLink>
</div> </div>
<div class="col col-md-4k col-lg-5 col-xl-6"> <div class="col col-md-4k col-lg-5 col-xl-6">
<SearchForm @loadMessages="loadMessages" /> <SearchForm @load-messages="loadMessages" />
</div> </div>
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0"> <div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0">
<div class="float-start d-md-none"> <div class="float-start d-md-none">
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas" <button
data-bs-target="#offcanvas" aria-controls="offcanvas"> class="btn btn-outline-light me-2"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvas"
aria-controls="offcanvas"
>
<i class="bi bi-list"></i> <i class="bi bi-list"></i>
</button> </button>
</div> </div>
@@ -145,20 +150,30 @@ export default {
</div> </div>
</div> </div>
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas" <div
aria-labelledby="offcanvasLabel"> id="offcanvas"
class="offcanvas-md offcanvas-start d-md-none"
data-bs-scroll="true"
tabindex="-1"
aria-labelledby="offcanvasLabel"
>
<div class="offcanvas-header"> <div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5> <h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas" <button
aria-label="Close"></button> type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target="#offcanvas"
aria-label="Close"
></button>
</div> </div>
<div class="offcanvas-body pb-0"> <div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100"> <div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3"> <div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavSearch @loadMessages="loadMessages" /> <NavSearch @load-messages="loadMessages" />
<NavTags /> <NavTags />
</div> </div>
<AboutMailpit /> <About />
</div> </div>
</div> </div>
</div> </div>
@@ -166,20 +181,20 @@ export default {
<div class="row flex-fill" style="min-height: 0"> <div class="row flex-fill" style="min-height: 0">
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column"> <div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3"> <div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavSearch @loadMessages="loadMessages" /> <NavSearch @load-messages="loadMessages" />
<NavTags /> <NavTags />
</div> </div>
<AboutMailpit /> <About />
</div> </div>
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0"> <div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page"> <div id="message-page" class="mh-100" style="overflow-y: auto">
<ListMessages :loading-messages="loading" /> <ListMessages :loading-messages="loading" />
</div> </div>
</div> </div>
</div> </div>
<NavSearch @loadMessages="loadMessages" modals /> <NavSearch modals @load-messages="loadMessages" />
<AboutMailpit modals /> <About modals />
<AjaxLoader :loading="loading" /> <AjaxLoader :loading="loading" />
</template> </template>