mirror of
				https://github.com/axllent/mailpit.git
				synced 2025-10-31 00:07:43 +02:00 
			
		
		
		
	Chore: Apply linting to all JavaScript/Vue files with eslint & prettier
This commit is contained in:
		
							
								
								
									
										9
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| # Not within the scope of Prettier | ||||
| **/*.yml | ||||
| **/*.yaml | ||||
| **/*.json | ||||
| **/*.md | ||||
| **/*.css | ||||
| **/*.html | ||||
| **/*.scss | ||||
| composer.lock | ||||
| @@ -1,44 +1,39 @@ | ||||
| import * as esbuild from 'esbuild' | ||||
| import pluginVue from 'esbuild-plugin-vue-next' | ||||
| import { sassPlugin } from 'esbuild-sass-plugin' | ||||
| import * as esbuild from "esbuild"; | ||||
| import pluginVue from "esbuild-plugin-vue-next"; | ||||
| import { sassPlugin } from "esbuild-sass-plugin"; | ||||
|  | ||||
| const doWatch = process.env.WATCH == 'true' ? true : false; | ||||
| const doMinify = process.env.MINIFY == 'true' ? true : false; | ||||
| const doWatch = process.env.WATCH === "true"; | ||||
| const doMinify = process.env.MINIFY === "true"; | ||||
|  | ||||
| const ctx = await esbuild.context( | ||||
|     { | ||||
|         entryPoints: [ | ||||
|             "server/ui-src/app.js", | ||||
|             "server/ui-src/docs.js" | ||||
|         ], | ||||
|         bundle: true, | ||||
|         minify: doMinify, | ||||
|         sourcemap: false, | ||||
|         define: { | ||||
|             '__VUE_OPTIONS_API__': 'true', | ||||
|             '__VUE_PROD_DEVTOOLS__': 'false', | ||||
|             '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false', | ||||
|         }, | ||||
|         outdir: "server/ui/dist/", | ||||
|         plugins: [ | ||||
|             pluginVue(), | ||||
|             sassPlugin({ | ||||
|                 silenceDeprecations: ['import'], | ||||
|                 quietDeps: true, | ||||
|             }) | ||||
|         ], | ||||
|         loader: { | ||||
|             ".svg": "file", | ||||
|             ".woff": "file", | ||||
|             ".woff2": "file", | ||||
|         }, | ||||
|         logLevel: "info" | ||||
|     } | ||||
| ) | ||||
| const ctx = await esbuild.context({ | ||||
| 	entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"], | ||||
| 	bundle: true, | ||||
| 	minify: doMinify, | ||||
| 	sourcemap: false, | ||||
| 	define: { | ||||
| 		__VUE_OPTIONS_API__: "true", | ||||
| 		__VUE_PROD_DEVTOOLS__: "false", | ||||
| 		__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false", | ||||
| 	}, | ||||
| 	outdir: "server/ui/dist/", | ||||
| 	plugins: [ | ||||
| 		pluginVue(), | ||||
| 		sassPlugin({ | ||||
| 			silenceDeprecations: ["import"], | ||||
| 			quietDeps: true, | ||||
| 		}), | ||||
| 	], | ||||
| 	loader: { | ||||
| 		".svg": "file", | ||||
| 		".woff": "file", | ||||
| 		".woff2": "file", | ||||
| 	}, | ||||
| 	logLevel: "info", | ||||
| }); | ||||
|  | ||||
| if (doWatch) { | ||||
|     await ctx.watch() | ||||
| 	await ctx.watch(); | ||||
| } else { | ||||
|     await ctx.rebuild() | ||||
|     ctx.dispose() | ||||
| 	await ctx.rebuild(); | ||||
| 	ctx.dispose(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										34
									
								
								eslint.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								eslint.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| import eslintConfigPrettier from "eslint-config-prettier/flat"; | ||||
| import neostandard, { resolveIgnoresFromGitignore } from "neostandard"; | ||||
| import vue from "eslint-plugin-vue"; | ||||
|  | ||||
| export default [ | ||||
| 	/* Baseline JS rules, provided by Neostandard */ | ||||
| 	...neostandard({ | ||||
| 		/* Allows references to browser APIs like `document` */ | ||||
| 		env: ["browser"], | ||||
|  | ||||
| 		/* We rely on .gitignore to avoid running against dist / dependency files */ | ||||
| 		ignores: resolveIgnoresFromGitignore(), | ||||
|  | ||||
| 		/* Disables a range of style-related rules, as we use Prettier for that */ | ||||
| 		noStyle: true, | ||||
|  | ||||
| 		/* Ensures we only lint JS and Vue files */ | ||||
| 		files: ["**/*.js", "**/*.vue"], | ||||
| 	}), | ||||
|  | ||||
| 	/* Vue-specific rules */ | ||||
| 	...vue.configs["flat/recommended"], | ||||
|  | ||||
| 	/* Prettier is responsible for formatting, so this disables any conflicting rules */ | ||||
| 	eslintConfigPrettier, | ||||
|  | ||||
| 	/* Our custom rules */ | ||||
| 	{ | ||||
| 		rules: { | ||||
| 			/* We prefer arrow functions for tidiness and consistency */ | ||||
| 			"prefer-arrow-callback": "error", | ||||
| 		}, | ||||
| 	}, | ||||
| ]; | ||||
							
								
								
									
										4338
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4338
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										17
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,12 +1,15 @@ | ||||
| { | ||||
|   "name": "mailpit", | ||||
|   "version": "0.0.0", | ||||
|   "type": "module", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "build": "MINIFY=true node esbuild.config.mjs", | ||||
|     "watch": "WATCH=true node esbuild.config.mjs", | ||||
|     "package": "MINIFY=true node esbuild.config.mjs", | ||||
|     "update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json" | ||||
|     "update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json", | ||||
|     "lint": "eslint --max-warnings 0 && prettier -c .", | ||||
|     "lint-fix": "eslint --fix && prettier --write ." | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "axios": "^1.2.1", | ||||
| @@ -33,6 +36,16 @@ | ||||
|     "@vue/compiler-sfc": "^3.2.37", | ||||
|     "esbuild": "^0.25.0", | ||||
|     "esbuild-plugin-vue-next": "^0.1.4", | ||||
|     "esbuild-sass-plugin": "^3.0.0" | ||||
|     "esbuild-sass-plugin": "^3.0.0", | ||||
|     "eslint": "^9.29.0", | ||||
|     "eslint-config-prettier": "^10.1.5", | ||||
|     "eslint-plugin-vue": "^10.2.0", | ||||
|     "neostandard": "^0.12.1", | ||||
|     "prettier": "^3.5.3" | ||||
|   }, | ||||
|   "prettier":{ | ||||
|     "tabWidth": 4, | ||||
|     "useTabs": true, | ||||
|     "printWidth": 120 | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,42 +1,41 @@ | ||||
| <script> | ||||
| import CommonMixins from './mixins/CommonMixins' | ||||
| import Favicon from './components/Favicon.vue' | ||||
| import AppBadge from './components/AppBadge.vue' | ||||
| import Notifications from './components/Notifications.vue' | ||||
| import EditTags from './components/EditTags.vue' | ||||
| import { mailbox } from "./stores/mailbox" | ||||
| import CommonMixins from "./mixins/CommonMixins"; | ||||
| import Favicon from "./components/AppFavicon.vue"; | ||||
| import AppBadge from "./components/AppBadge.vue"; | ||||
| import Notifications from "./components/AppNotifications.vue"; | ||||
| import EditTags from "./components/EditTags.vue"; | ||||
| import { mailbox } from "./stores/mailbox"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		Favicon, | ||||
| 		AppBadge, | ||||
| 		Notifications, | ||||
| 		EditTags | ||||
| 		EditTags, | ||||
| 	}, | ||||
|  | ||||
| 	beforeMount() { | ||||
| 		// load global config | ||||
| 		this.get(this.resolve('/api/v1/webui'), false, function (response) { | ||||
| 			mailbox.uiConfig = response.data | ||||
|  | ||||
| 			if (mailbox.uiConfig.Label) { | ||||
| 				document.title = document.title + ' - ' + mailbox.uiConfig.Label | ||||
| 			} else { | ||||
| 				document.title = document.title + ' - ' + location.hostname | ||||
| 			} | ||||
| 		}) | ||||
| 	}, | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	watch: { | ||||
| 		$route(to, from) { | ||||
| 			// hide mobile menu on URL change | ||||
| 			this.hideNav() | ||||
| 		} | ||||
| 			this.hideNav(); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| } | ||||
| 	beforeMount() { | ||||
| 		// load global config | ||||
| 		this.get(this.resolve("/api/v1/webui"), false, (response) => { | ||||
| 			mailbox.uiConfig = response.data; | ||||
|  | ||||
| 			if (mailbox.uiConfig.Label) { | ||||
| 				document.title = document.title + " - " + mailbox.uiConfig.Label; | ||||
| 			} else { | ||||
| 				document.title = document.title + " - " + location.hostname; | ||||
| 			} | ||||
| 		}); | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   | ||||
| @@ -1,19 +1,19 @@ | ||||
| import App from './App.vue' | ||||
| import router from './router' | ||||
| import { createApp } from 'vue' | ||||
| import mitt from 'mitt'; | ||||
| import App from "./App.vue"; | ||||
| import router from "./router"; | ||||
| import { createApp } from "vue"; | ||||
| import mitt from "mitt"; | ||||
|  | ||||
| import './assets/styles.scss' | ||||
| import 'bootstrap-icons/font/bootstrap-icons.scss' | ||||
| import 'bootstrap' | ||||
| import 'vue-css-donut-chart/src/styles/main.css' | ||||
| import "./assets/styles.scss"; | ||||
| import "bootstrap-icons/font/bootstrap-icons.scss"; | ||||
| import "bootstrap"; | ||||
| import "vue-css-donut-chart/src/styles/main.css"; | ||||
|  | ||||
| const app = createApp(App) | ||||
| const app = createApp(App); | ||||
|  | ||||
| // Global event bus used to subscribe to websocket events | ||||
| // such as message deletes, updates & truncation. | ||||
| const eventBus = mitt() | ||||
| app.provide('eventBus', eventBus) | ||||
| const eventBus = mitt(); | ||||
| app.provide("eventBus", eventBus); | ||||
|  | ||||
| app.use(router) | ||||
| app.mount('#app') | ||||
| app.use(router); | ||||
| app.mount("#app"); | ||||
|   | ||||
| @@ -1,13 +1,16 @@ | ||||
| <script> | ||||
| export default { | ||||
| 	props: { | ||||
| 		loading: Number, | ||||
| 		loading: { | ||||
| 			type: Number, | ||||
| 			default: 0, | ||||
| 		}, | ||||
| 	}, | ||||
| } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="loader" v-if="loading > 0"> | ||||
| 	<div v-if="loading > 0" class="loader"> | ||||
| 		<div class="d-flex justify-content-center align-items-center h-100"> | ||||
| 			<div class="spinner-border text-muted" role="status"> | ||||
| 				<span class="visually-hidden">Loading...</span> | ||||
|   | ||||
| @@ -1,75 +1,83 @@ | ||||
| <script> | ||||
| import AjaxLoader from './AjaxLoader.vue' | ||||
| import Settings from '../components/Settings.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import AjaxLoader from "./AjaxLoader.vue"; | ||||
| import Settings from "./AppSettings.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| 
 | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| 
 | ||||
| 	components: { | ||||
| 		AjaxLoader, | ||||
| 		Settings, | ||||
| 	}, | ||||
| 
 | ||||
| 	mixins: [CommonMixins], | ||||
| 
 | ||||
| 	props: { | ||||
| 		modals: { | ||||
| 			type: Boolean, | ||||
| 			default: false, | ||||
| 		} | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		loadInfo() { | ||||
| 			this.get(this.resolve('/api/v1/info'), false, (response) => { | ||||
| 				mailbox.appInfo = response.data | ||||
| 				this.modal('AppInfoModal').show() | ||||
| 			}) | ||||
| 			this.get(this.resolve("/api/v1/info"), false, (response) => { | ||||
| 				mailbox.appInfo = response.data; | ||||
| 				this.modal("AppInfoModal").show(); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		requestNotifications() { | ||||
| 			// check if the browser supports notifications | ||||
| 			if (!("Notification" in window)) { | ||||
| 				alert("This browser does not support desktop notifications") | ||||
| 				alert("This browser does not support desktop notifications"); | ||||
| 			} | ||||
| 
 | ||||
| 			// we need to ask the user for permission | ||||
| 			else if (Notification.permission !== "denied") { | ||||
| 				Notification.requestPermission().then((permission) => { | ||||
| 					if (permission === "granted") { | ||||
| 						mailbox.notificationsEnabled = true | ||||
| 						mailbox.notificationsEnabled = true; | ||||
| 					} | ||||
| 
 | ||||
| 					this.modal('EnableNotificationsModal').hide() | ||||
| 				}) | ||||
| 					this.modal("EnableNotificationsModal").hide(); | ||||
| 				}); | ||||
| 			} | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
| 	<template v-if="!modals"> | ||||
| 		<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit"> | ||||
| 			<button class="text-muted btn btn-sm" v-on:click="loadInfo()"> | ||||
| 			<button class="text-muted btn btn-sm" @click="loadInfo()"> | ||||
| 				<i class="bi bi-info-circle-fill me-1"></i> | ||||
| 				About | ||||
| 			</button> | ||||
| 
 | ||||
| 			<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal" | ||||
| 				data-bs-target="#SettingsModal" title="Mailpit UI settings"> | ||||
| 			<button | ||||
| 				class="btn btn-sm btn-outline-secondary float-end" | ||||
| 				data-bs-toggle="modal" | ||||
| 				data-bs-target="#SettingsModal" | ||||
| 				title="Mailpit UI settings" | ||||
| 			> | ||||
| 				<i class="bi bi-gear-fill"></i> | ||||
| 			</button> | ||||
| 
 | ||||
| 			<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal" | ||||
| 				data-bs-target="#EnableNotificationsModal" title="Enable browser notifications" | ||||
| 				v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled"> | ||||
| 			<button | ||||
| 				v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled" | ||||
| 				class="btn btn-sm btn-outline-secondary float-end me-2" | ||||
| 				data-bs-toggle="modal" | ||||
| 				data-bs-target="#EnableNotificationsModal" | ||||
| 				title="Enable browser notifications" | ||||
| 			> | ||||
| 				<i class="bi bi-bell"></i> | ||||
| 			</button> | ||||
| 		</div> | ||||
| @@ -77,12 +85,17 @@ export default { | ||||
| 
 | ||||
| 	<template v-else> | ||||
| 		<!-- Modals --> | ||||
| 		<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" | ||||
| 			aria-hidden="true"> | ||||
| 		<div | ||||
| 			id="AppInfoModal" | ||||
| 			class="modal modal-xl fade" | ||||
| 			tabindex="-1" | ||||
| 			aria-labelledby="AppInfoModalLabel" | ||||
| 			aria-hidden="true" | ||||
| 		> | ||||
| 			<div class="modal-dialog"> | ||||
| 				<div class="modal-content" v-if="mailbox.appInfo.RuntimeStats"> | ||||
| 				<div v-if="mailbox.appInfo.RuntimeStats" class="modal-content"> | ||||
| 					<div class="modal-header"> | ||||
| 						<h5 class="modal-title" id="AppInfoModalLabel"> | ||||
| 						<h5 id="AppInfoModalLabel" class="modal-title"> | ||||
| 							Mailpit | ||||
| 							<code>({{ mailbox.appInfo.Version }})</code> | ||||
| 						</h5> | ||||
| @@ -92,19 +105,27 @@ export default { | ||||
| 						<div class="row g-3"> | ||||
| 							<div class="col-xl-6"> | ||||
| 								<div v-if="mailbox.appInfo.LatestVersion != 'disabled'"> | ||||
| 									<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''"> | ||||
| 									<div v-if="mailbox.appInfo.LatestVersion == ''" class="row g-3"> | ||||
| 										<div class="col"> | ||||
| 											<div class="alert alert-warning mb-3"> | ||||
| 												There might be a newer version available. The check failed. | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 									<div class="row g-3" | ||||
| 										v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"> | ||||
| 									<div | ||||
| 										v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion" | ||||
| 										class="row g-3" | ||||
| 									> | ||||
| 										<div class="col"> | ||||
| 											<a class="btn btn-warning d-block mb-3" | ||||
| 												:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion"> | ||||
| 												A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available. | ||||
| 											<a | ||||
| 												class="btn btn-warning d-block mb-3" | ||||
| 												:href=" | ||||
| 													'https://github.com/axllent/mailpit/releases/tag/' + | ||||
| 													mailbox.appInfo.LatestVersion | ||||
| 												" | ||||
| 											> | ||||
| 												A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is | ||||
| 												available. | ||||
| 											</a> | ||||
| 										</div> | ||||
| 									</div> | ||||
| @@ -117,15 +138,21 @@ export default { | ||||
| 										</RouterLink> | ||||
| 									</div> | ||||
| 									<div class="col-sm-6"> | ||||
| 										<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit" | ||||
| 											target="_blank"> | ||||
| 										<a | ||||
| 											class="btn btn-primary w-100" | ||||
| 											href="https://github.com/axllent/mailpit" | ||||
| 											target="_blank" | ||||
| 										> | ||||
| 											<i class="bi bi-github"></i> | ||||
| 											Github | ||||
| 										</a> | ||||
| 									</div> | ||||
| 									<div class="col-sm-6"> | ||||
| 										<a class="btn btn-primary w-100" href="https://mailpit.axllent.org/docs/" | ||||
| 											target="_blank"> | ||||
| 										<a | ||||
| 											class="btn btn-primary w-100" | ||||
| 											href="https://mailpit.axllent.org/docs/" | ||||
| 											target="_blank" | ||||
| 										> | ||||
| 											Documentation | ||||
| 										</a> | ||||
| 									</div> | ||||
| @@ -133,7 +160,8 @@ export default { | ||||
| 										<div class="card border-secondary text-center"> | ||||
| 											<div class="card-header">Database size</div> | ||||
| 											<div class="card-body text-muted"> | ||||
| 												<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }} | ||||
| 												<h5 class="card-title"> | ||||
| 													{{ getFileSize(mailbox.appInfo.DatabaseSize) }} | ||||
| 												</h5> | ||||
| 											</div> | ||||
| 										</div> | ||||
| @@ -154,8 +182,7 @@ export default { | ||||
| 								<div class="card border-secondary h-100"> | ||||
| 									<div class="card-header h4"> | ||||
| 										Runtime statistics | ||||
| 										<button class="btn btn-sm btn-outline-secondary float-end" | ||||
| 											v-on:click="loadInfo()"> | ||||
| 										<button class="btn btn-sm btn-outline-secondary float-end" @click="loadInfo()"> | ||||
| 											Refresh | ||||
| 										</button> | ||||
| 									</div> | ||||
| @@ -163,46 +190,38 @@ export default { | ||||
| 										<table class="table table-sm table-borderless mb-0"> | ||||
| 											<tbody> | ||||
| 												<tr> | ||||
| 													<td> | ||||
| 														Mailpit up since | ||||
| 													</td> | ||||
| 													<td>Mailpit up since</td> | ||||
| 													<td> | ||||
| 														{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }} | ||||
| 													</td> | ||||
| 												</tr> | ||||
| 												<tr> | ||||
| 													<td> | ||||
| 														Messages deleted | ||||
| 													</td> | ||||
| 													<td>Messages deleted</td> | ||||
| 													<td> | ||||
| 														{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }} | ||||
| 													</td> | ||||
| 												</tr> | ||||
| 												<tr> | ||||
| 													<td> | ||||
| 														SMTP messages accepted | ||||
| 													</td> | ||||
| 													<td>SMTP messages accepted</td> | ||||
| 													<td> | ||||
| 														{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }} | ||||
| 														<small class="text-muted"> | ||||
| 															({{ | ||||
| 																getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize) | ||||
| 																getFileSize( | ||||
| 																	mailbox.appInfo.RuntimeStats.SMTPAcceptedSize, | ||||
| 																) | ||||
| 															}}) | ||||
| 														</small> | ||||
| 													</td> | ||||
| 												</tr> | ||||
| 												<tr> | ||||
| 													<td> | ||||
| 														SMTP messages rejected | ||||
| 													</td> | ||||
| 													<td>SMTP messages rejected</td> | ||||
| 													<td> | ||||
| 														{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }} | ||||
| 													</td> | ||||
| 												</tr> | ||||
| 												<tr v-if="mailbox.uiConfig.DuplicatesIgnored"> | ||||
| 													<td> | ||||
| 														SMTP messages ignored | ||||
| 													</td> | ||||
| 													<td>SMTP messages ignored</td> | ||||
| 													<td> | ||||
| 														{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }} | ||||
| 													</td> | ||||
| @@ -210,12 +229,9 @@ export default { | ||||
| 											</tbody> | ||||
| 										</table> | ||||
| 									</div> | ||||
| 
 | ||||
| 								</div> | ||||
| 
 | ||||
| 							</div> | ||||
| 						</div> | ||||
| 
 | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button> | ||||
| @@ -224,26 +240,30 @@ export default { | ||||
| 			</div> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" | ||||
| 			aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true"> | ||||
| 		<div | ||||
| 			id="EnableNotificationsModal" | ||||
| 			class="modal fade" | ||||
| 			tabindex="-1" | ||||
| 			aria-labelledby="EnableNotificationsModalLabel" | ||||
| 			aria-hidden="true" | ||||
| 		> | ||||
| 			<div class="modal-dialog modal-lg"> | ||||
| 				<div class="modal-content"> | ||||
| 					<div class="modal-header"> | ||||
| 						<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5> | ||||
| 						<h5 id="EnableNotificationsModalLabel" class="modal-title">Enable browser notifications?</h5> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						<p class="h4">Get browser notifications when Mailpit receives new messages?</p> | ||||
| 						<p> | ||||
| 							Note that your browser will ask you for confirmation when you click | ||||
| 							<code>enable notifications</code>, | ||||
| 							and that you must have Mailpit open in a browser tab to be able to receive the | ||||
| 							notifications. | ||||
| 							<code>enable notifications</code>, and that you must have Mailpit open in a browser tab to | ||||
| 							be able to receive the notifications. | ||||
| 						</p> | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | ||||
| 						<button type="button" class="btn btn-success" v-on:click="requestNotifications"> | ||||
| 						<button type="button" class="btn btn-success" @click="requestNotifications"> | ||||
| 							Enable notifications | ||||
| 						</button> | ||||
| 					</div> | ||||
| @@ -1,59 +1,57 @@ | ||||
| <script> | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
| import { mailbox } from "../stores/mailbox.js"; | ||||
|  | ||||
| export default { | ||||
|     data() { | ||||
|         return { | ||||
|             updating: false, | ||||
|             needsUpdate: false, | ||||
|             timeout: 500, | ||||
|         } | ||||
|     }, | ||||
| 	data() { | ||||
| 		return { | ||||
| 			updating: false, | ||||
| 			needsUpdate: false, | ||||
| 			timeout: 500, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
|     computed: { | ||||
|         mailboxUnread() { | ||||
|             return mailbox.unread | ||||
|         } | ||||
|     }, | ||||
| 	computed: { | ||||
| 		mailboxUnread() { | ||||
| 			return mailbox.unread; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
|     watch: { | ||||
|         mailboxUnread: { | ||||
|             handler() { | ||||
|                 if (this.updating) { | ||||
|                     this.needsUpdate = true | ||||
|                     return | ||||
|                 } | ||||
| 	watch: { | ||||
| 		mailboxUnread: { | ||||
| 			handler() { | ||||
| 				if (this.updating) { | ||||
| 					this.needsUpdate = true; | ||||
| 					return; | ||||
| 				} | ||||
|  | ||||
|                 this.scheduleUpdate() | ||||
|             }, | ||||
|             immediate: true | ||||
|         } | ||||
|     }, | ||||
| 				this.scheduleUpdate(); | ||||
| 			}, | ||||
| 			immediate: true, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
|     methods: { | ||||
|         scheduleUpdate() { | ||||
|             this.updating = true | ||||
|             this.needsUpdate = false | ||||
| 	methods: { | ||||
| 		scheduleUpdate() { | ||||
| 			this.updating = true; | ||||
| 			this.needsUpdate = false; | ||||
|  | ||||
|             window.setTimeout(() => { | ||||
|                 this.updateAppBadge() | ||||
|                 this.updating = false | ||||
| 			window.setTimeout(() => { | ||||
| 				this.updateAppBadge(); | ||||
| 				this.updating = false; | ||||
|  | ||||
|                 if (this.needsUpdate) { | ||||
|                     this.scheduleUpdate() | ||||
|                 } | ||||
|             }, this.timeout) | ||||
|         }, | ||||
| 				if (this.needsUpdate) { | ||||
| 					this.scheduleUpdate(); | ||||
| 				} | ||||
| 			}, this.timeout); | ||||
| 		}, | ||||
|  | ||||
|         updateAppBadge() { | ||||
|             if (!('setAppBadge' in navigator)) { | ||||
|                 return | ||||
|             } | ||||
| 		updateAppBadge() { | ||||
| 			if (!("setAppBadge" in navigator)) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
|             navigator.setAppBadge(this.mailboxUnread) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 			navigator.setAppBadge(this.mailboxUnread); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template></template> | ||||
|   | ||||
							
								
								
									
										116
									
								
								server/ui-src/components/AppFavicon.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								server/ui-src/components/AppFavicon.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| <script> | ||||
| import { mailbox } from "../stores/mailbox.js"; | ||||
|  | ||||
| export default { | ||||
| 	data() { | ||||
| 		return { | ||||
| 			favicon: false, | ||||
| 			iconPath: false, | ||||
| 			iconTextColor: "#ffffff", | ||||
| 			iconBgColor: "#dd0000", | ||||
| 			iconFontSize: 40, | ||||
| 			iconProcessing: false, | ||||
| 			iconTimeout: 500, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		count() { | ||||
| 			let i = mailbox.unread; | ||||
| 			if (i > 1000) { | ||||
| 				i = Math.floor(i / 1000) + "k"; | ||||
| 			} | ||||
|  | ||||
| 			return i; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		count() { | ||||
| 			if (!this.favicon || this.iconProcessing) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			this.iconProcessing = true; | ||||
|  | ||||
| 			window.setTimeout(() => { | ||||
| 				this.icoUpdate(); | ||||
| 			}, this.iconTimeout); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.favicon = document.head.querySelector('link[rel="icon"]'); | ||||
| 		if (this.favicon) { | ||||
| 			this.iconPath = this.favicon.href; | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		async icoUpdate() { | ||||
| 			if (!this.favicon) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			if (!this.count) { | ||||
| 				this.iconProcessing = false; | ||||
| 				this.favicon.href = this.iconPath; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			let fontSize = this.iconFontSize; | ||||
| 			// Draw badge text | ||||
| 			let textPaddingX = 7; | ||||
| 			const textPaddingY = 3; | ||||
|  | ||||
| 			const strlen = this.count.toString().length; | ||||
|  | ||||
| 			if (strlen > 2) { | ||||
| 				// if text >= 3 characters then reduce size and padding | ||||
| 				textPaddingX = 4; | ||||
| 				fontSize = strlen > 3 ? 30 : 36; | ||||
| 			} | ||||
|  | ||||
| 			const canvas = document.createElement("canvas"); | ||||
| 			canvas.width = 64; | ||||
| 			canvas.height = 64; | ||||
|  | ||||
| 			const ctx = canvas.getContext("2d"); | ||||
|  | ||||
| 			// Draw base icon | ||||
| 			const icon = new Image(); | ||||
| 			icon.src = this.iconPath; | ||||
| 			await icon.decode(); | ||||
|  | ||||
| 			ctx.drawImage(icon, 0, 0, 64, 64); | ||||
|  | ||||
| 			// Measure text | ||||
| 			ctx.font = `${fontSize}px Arial, sans-serif`; | ||||
| 			ctx.textAlign = "right"; | ||||
| 			ctx.textBaseline = "top"; | ||||
| 			const textMetrics = ctx.measureText(this.count); | ||||
|  | ||||
| 			// Draw badge | ||||
| 			const paddingX = 7; | ||||
| 			const paddingY = 4; | ||||
| 			const cornerRadius = 8; | ||||
|  | ||||
| 			const width = textMetrics.width + paddingX * 2; | ||||
| 			const height = fontSize + paddingY * 2; | ||||
| 			const x = canvas.width - width; | ||||
| 			const y = canvas.height - height - 1; | ||||
|  | ||||
| 			ctx.fillStyle = this.iconBgColor; | ||||
| 			ctx.roundRect(x, y, width, height, cornerRadius); | ||||
| 			ctx.fill(); | ||||
|  | ||||
| 			ctx.fillStyle = this.iconTextColor; | ||||
| 			ctx.fillText(this.count, canvas.width - textPaddingX, canvas.height - fontSize - textPaddingY); | ||||
|  | ||||
| 			this.iconProcessing = false; | ||||
|  | ||||
| 			this.favicon.href = canvas.toDataURL("image/png"); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
							
								
								
									
										289
									
								
								server/ui-src/components/AppNotifications.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										289
									
								
								server/ui-src/components/AppNotifications.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,289 @@ | ||||
| <script> | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { Toast } from "bootstrap"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			pagination, | ||||
| 			mailbox, | ||||
| 			toastMessage: false, | ||||
| 			reconnectRefresh: false, | ||||
| 			socketURI: false, | ||||
| 			socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections | ||||
| 			socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s | ||||
| 			pauseNotifications: false, // prevent spamming | ||||
| 			version: false, | ||||
| 			clientErrors: [], // errors received via websocket | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		const d = document.getElementById("app"); | ||||
| 		if (d) { | ||||
| 			this.version = d.dataset.version; | ||||
| 		} | ||||
|  | ||||
| 		const proto = location.protocol === "https:" ? "wss" : "ws"; | ||||
| 		this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`); | ||||
|  | ||||
| 		this.socketBreakReset(); | ||||
| 		this.connect(); | ||||
|  | ||||
| 		mailbox.notificationsSupported = | ||||
| 			window.isSecureContext && "Notification" in window && Notification.permission !== "denied"; | ||||
| 		mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission === "granted"; | ||||
|  | ||||
| 		this.errorNotificationCron(); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		// websocket connect | ||||
| 		connect() { | ||||
| 			const ws = new WebSocket(this.socketURI); | ||||
| 			ws.onmessage = (e) => { | ||||
| 				let response; | ||||
| 				try { | ||||
| 					response = JSON.parse(e.data); | ||||
| 				} catch (e) { | ||||
| 					return; | ||||
| 				} | ||||
|  | ||||
| 				// new messages | ||||
| 				if (response.Type === "new" && response.Data) { | ||||
| 					this.eventBus.emit("new", response.Data); | ||||
|  | ||||
| 					for (const i in response.Data.Tags) { | ||||
| 						if ( | ||||
| 							mailbox.tags.findIndex((e) => { | ||||
| 								return e.toLowerCase() === response.Data.Tags[i].toLowerCase(); | ||||
| 							}) < 0 | ||||
| 						) { | ||||
| 							mailbox.tags.push(response.Data.Tags[i]); | ||||
| 							mailbox.tags.sort((a, b) => { | ||||
| 								return a.toLowerCase().localeCompare(b.toLowerCase()); | ||||
| 							}); | ||||
| 						} | ||||
| 					} | ||||
|  | ||||
| 					// send notifications | ||||
| 					if (!this.pauseNotifications) { | ||||
| 						this.pauseNotifications = true; | ||||
| 						const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]"; | ||||
| 						this.browserNotify("New mail from: " + from, response.Data.Subject); | ||||
| 						this.setMessageToast(response.Data); | ||||
| 						// delay notifications by 2s | ||||
| 						window.setTimeout(() => { | ||||
| 							this.pauseNotifications = false; | ||||
| 						}, 2000); | ||||
| 					} | ||||
| 				} else if (response.Type === "prune") { | ||||
| 					// messages have been deleted, reload messages to adjust | ||||
| 					window.scrollInPlace = true; | ||||
| 					mailbox.refresh = true; // trigger refresh | ||||
| 					window.setTimeout(() => { | ||||
| 						mailbox.refresh = false; | ||||
| 					}, 500); | ||||
| 					this.eventBus.emit("prune"); | ||||
| 				} else if (response.Type === "stats" && response.Data) { | ||||
| 					// refresh mailbox stats | ||||
| 					mailbox.total = response.Data.Total; | ||||
| 					mailbox.unread = response.Data.Unread; | ||||
|  | ||||
| 					// detect version updated, refresh is needed | ||||
| 					if (this.version !== response.Data.Version) { | ||||
| 						location.reload(); | ||||
| 					} | ||||
| 				} else if (response.Type === "delete" && response.Data) { | ||||
| 					// broadcast for components | ||||
| 					this.eventBus.emit("delete", response.Data); | ||||
| 				} else if (response.Type === "update" && response.Data) { | ||||
| 					// broadcast for components | ||||
| 					this.eventBus.emit("update", response.Data); | ||||
| 				} else if (response.Type === "truncate") { | ||||
| 					// broadcast for components | ||||
| 					this.eventBus.emit("truncate"); | ||||
| 				} else if (response.Type === "error") { | ||||
| 					// broadcast for components | ||||
| 					this.addClientError(response.Data); | ||||
| 				} | ||||
| 			}; | ||||
|  | ||||
| 			ws.onopen = () => { | ||||
| 				mailbox.connected = true; | ||||
| 				this.socketLastConnection = Date.now(); | ||||
| 				if (this.reconnectRefresh) { | ||||
| 					this.reconnectRefresh = false; | ||||
| 					mailbox.refresh = true; // trigger refresh | ||||
| 					window.setTimeout(() => { | ||||
| 						mailbox.refresh = false; | ||||
| 					}, 500); | ||||
| 				} | ||||
| 			}; | ||||
|  | ||||
| 			ws.onclose = (e) => { | ||||
| 				if (this.socketLastConnection === 0) { | ||||
| 					// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured | ||||
| 					console.log("Unable to connect to websocket, disabling websocket support"); | ||||
| 					return; | ||||
| 				} | ||||
|  | ||||
| 				if (mailbox.connected) { | ||||
| 					// count disconnections | ||||
| 					this.socketBreaks++; | ||||
| 				} | ||||
|  | ||||
| 				// set disconnected state | ||||
| 				mailbox.connected = false; | ||||
|  | ||||
| 				if (this.socketBreaks > 3) { | ||||
| 					// give up after > 3 successful socket connections & disconnections within a 15 second window, | ||||
| 					// something is not working right on their end, see issue #319 | ||||
| 					console.log("Unstable websocket connection, disabling websocket support"); | ||||
| 					return; | ||||
| 				} | ||||
| 				if (Date.now() - this.socketLastConnection > 5000) { | ||||
| 					// only refresh mailbox if the last successful connection was broken for > 5 seconds | ||||
| 					this.reconnectRefresh = true; | ||||
| 				} else { | ||||
| 					this.reconnectRefresh = false; | ||||
| 				} | ||||
|  | ||||
| 				setTimeout(() => { | ||||
| 					this.connect(); // reconnect | ||||
| 				}, 1000); | ||||
| 			}; | ||||
|  | ||||
| 			ws.onerror = function () { | ||||
| 				ws.close(); | ||||
| 			}; | ||||
| 		}, | ||||
|  | ||||
| 		socketBreakReset() { | ||||
| 			window.setTimeout(() => { | ||||
| 				this.socketBreaks = 0; | ||||
| 				this.socketBreakReset(); | ||||
| 			}, 15000); | ||||
| 		}, | ||||
|  | ||||
| 		browserNotify(title, message) { | ||||
| 			if (!("Notification" in window)) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			if (Notification.permission === "granted") { | ||||
| 				const options = { | ||||
| 					body: message, | ||||
| 					icon: this.resolve("/notification.png"), | ||||
| 				}; | ||||
|  | ||||
| 				(() => new Notification(title, options))(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		setMessageToast(m) { | ||||
| 			// don't display if browser notifications are enabled, or a toast is already displayed | ||||
| 			if (mailbox.notificationsEnabled || this.toastMessage) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			this.toastMessage = m; | ||||
|  | ||||
| 			const el = document.getElementById("messageToast"); | ||||
| 			if (el) { | ||||
| 				el.addEventListener("hidden.bs.toast", () => { | ||||
| 					this.toastMessage = false; | ||||
| 				}); | ||||
|  | ||||
| 				Toast.getOrCreateInstance(el).show(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		closeToast() { | ||||
| 			const el = document.getElementById("messageToast"); | ||||
| 			if (el) { | ||||
| 				Toast.getOrCreateInstance(el).hide(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		addClientError(d) { | ||||
| 			d.expire = Date.now() + 5000; // expire after 5s | ||||
| 			this.clientErrors.push(d); | ||||
| 		}, | ||||
|  | ||||
| 		errorNotificationCron() { | ||||
| 			window.setTimeout(() => { | ||||
| 				this.clientErrors.forEach((err, idx) => { | ||||
| 					if (err.expire < Date.now()) { | ||||
| 						this.clientErrors.splice(idx, 1); | ||||
| 					} | ||||
| 				}); | ||||
| 				this.errorNotificationCron(); | ||||
| 			}, 1000); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="toast-container position-fixed bottom-0 end-0 p-3"> | ||||
| 		<div | ||||
| 			v-for="(error, i) in clientErrors" | ||||
| 			:key="'error_' + i" | ||||
| 			class="toast show" | ||||
| 			role="alert" | ||||
| 			aria-live="assertive" | ||||
| 			aria-atomic="true" | ||||
| 		> | ||||
| 			<div class="toast-header"> | ||||
| 				<svg | ||||
| 					class="bd-placeholder-img rounded me-2" | ||||
| 					width="20" | ||||
| 					height="20" | ||||
| 					xmlns="http://www.w3.org/2000/svg" | ||||
| 					aria-hidden="true" | ||||
| 					preserveAspectRatio="xMidYMid slice" | ||||
| 					focusable="false" | ||||
| 				> | ||||
| 					<rect width="100%" height="100%" :fill="error.Level === 'warning' ? '#ffc107' : '#dc3545'"></rect> | ||||
| 				</svg> | ||||
| 				<strong class="me-auto">{{ error.Type }}</strong> | ||||
| 				<small class="text-body-secondary">{{ error.IP }}</small> | ||||
| 				<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> | ||||
| 			</div> | ||||
| 			<div class="toast-body"> | ||||
| 				{{ error.Message }} | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true"> | ||||
| 			<div v-if="toastMessage" class="toast-header"> | ||||
| 				<i class="bi bi-envelope-exclamation-fill me-2"></i> | ||||
| 				<strong class="me-auto"> | ||||
| 					<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink> | ||||
| 				</strong> | ||||
| 				<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="toast-body"> | ||||
| 				<div> | ||||
| 					<RouterLink | ||||
| 						:to="'/view/' + toastMessage.ID" | ||||
| 						class="d-block text-truncate text-body-secondary" | ||||
| 						@click="closeToast" | ||||
| 					> | ||||
| 						<template v-if="toastMessage.Subject !== ''">{{ toastMessage.Subject }}</template> | ||||
| 						<template v-else> [ no subject ] </template> | ||||
| 					</RouterLink> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
							
								
								
									
										381
									
								
								server/ui-src/components/AppSettings.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								server/ui-src/components/AppSettings.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,381 @@ | ||||
| <script> | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import Tags from "bootstrap5-tags"; | ||||
| import timezones from "timezones-list"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			theme: localStorage.getItem("theme") ? localStorage.getItem("theme") : "auto", | ||||
| 			timezones, | ||||
| 			chaosConfig: false, | ||||
| 			chaosUpdated: false, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		theme(v) { | ||||
| 			if (v === "auto") { | ||||
| 				localStorage.removeItem("theme"); | ||||
| 			} else { | ||||
| 				localStorage.setItem("theme", v); | ||||
| 			} | ||||
| 			this.setTheme(); | ||||
| 		}, | ||||
|  | ||||
| 		chaosConfig: { | ||||
| 			handler() { | ||||
| 				this.chaosUpdated = true; | ||||
| 			}, | ||||
| 			deep: true, | ||||
| 		}, | ||||
|  | ||||
| 		"mailbox.skipConfirmations"(v) { | ||||
| 			if (v) { | ||||
| 				localStorage.setItem("skip-confirmations", "true"); | ||||
| 			} else { | ||||
| 				localStorage.removeItem("skip-confirmations"); | ||||
| 			} | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.setTheme(); | ||||
| 		this.$nextTick(() => { | ||||
| 			Tags.init("select.tz"); | ||||
| 		}); | ||||
|  | ||||
| 		mailbox.skipConfirmations = !!localStorage.getItem("skip-confirmations"); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		setTheme() { | ||||
| 			if (this.theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) { | ||||
| 				document.documentElement.setAttribute("data-bs-theme", "dark"); | ||||
| 			} else { | ||||
| 				document.documentElement.setAttribute("data-bs-theme", this.theme); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		loadChaos() { | ||||
| 			this.get(this.resolve("/api/v1/chaos"), null, (response) => { | ||||
| 				this.chaosConfig = response.data; | ||||
| 				this.$nextTick(() => { | ||||
| 					this.chaosUpdated = false; | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		saveChaos() { | ||||
| 			this.put(this.resolve("/api/v1/chaos"), this.chaosConfig, (response) => { | ||||
| 				this.chaosConfig = response.data; | ||||
| 				this.$nextTick(() => { | ||||
| 					this.chaosUpdated = false; | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div | ||||
| 		id="SettingsModal" | ||||
| 		class="modal fade" | ||||
| 		tabindex="-1" | ||||
| 		aria-labelledby="SettingsModalLabel" | ||||
| 		aria-hidden="true" | ||||
| 		data-bs-keyboard="false" | ||||
| 	> | ||||
| 		<div class="modal-dialog modal-lg"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h5 id="SettingsModalLabel" class="modal-title">Mailpit settings</h5> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<ul v-if="mailbox.uiConfig.ChaosEnabled" id="myTab" class="nav nav-tabs" role="tablist"> | ||||
| 						<li class="nav-item" role="presentation"> | ||||
| 							<button | ||||
| 								id="ui-tab" | ||||
| 								class="nav-link active" | ||||
| 								data-bs-toggle="tab" | ||||
| 								data-bs-target="#ui-tab-pane" | ||||
| 								type="button" | ||||
| 								role="tab" | ||||
| 								aria-controls="ui-tab-pane" | ||||
| 								aria-selected="true" | ||||
| 							> | ||||
| 								Web UI | ||||
| 							</button> | ||||
| 						</li> | ||||
| 						<li class="nav-item" role="presentation"> | ||||
| 							<button | ||||
| 								id="chaos-tab" | ||||
| 								class="nav-link" | ||||
| 								data-bs-toggle="tab" | ||||
| 								data-bs-target="#chaos-tab-pane" | ||||
| 								type="button" | ||||
| 								role="tab" | ||||
| 								aria-controls="chaos-tab-pane" | ||||
| 								aria-selected="false" | ||||
| 								@click="loadChaos" | ||||
| 							> | ||||
| 								Chaos | ||||
| 							</button> | ||||
| 						</li> | ||||
| 					</ul> | ||||
|  | ||||
| 					<div class="tab-content"> | ||||
| 						<div | ||||
| 							id="ui-tab-pane" | ||||
| 							class="tab-pane fade show active" | ||||
| 							role="tabpanel" | ||||
| 							aria-labelledby="ui-tab" | ||||
| 							tabindex="0" | ||||
| 						> | ||||
| 							<div class="my-3"> | ||||
| 								<label for="theme" class="form-label">Mailpit theme</label> | ||||
| 								<select id="theme" v-model="theme" class="form-select"> | ||||
| 									<option value="auto">Auto (detect from browser)</option> | ||||
| 									<option value="light">Light theme</option> | ||||
| 									<option value="dark">Dark theme</option> | ||||
| 								</select> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<label for="timezone" class="form-label">Timezone (for date searches)</label> | ||||
| 								<select | ||||
| 									id="timezone" | ||||
| 									v-model="mailbox.timeZone" | ||||
| 									class="form-select tz" | ||||
| 									data-allow-same="true" | ||||
| 								> | ||||
| 									<option disabled hidden value="">Select a timezone...</option> | ||||
| 									<option v-for="t in timezones" :key="t" :value="t.tzCode">{{ t.label }}</option> | ||||
| 								</select> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input | ||||
| 										id="tagColors" | ||||
| 										v-model="mailbox.showTagColors" | ||||
| 										class="form-check-input" | ||||
| 										type="checkbox" | ||||
| 										role="switch" | ||||
| 									/> | ||||
| 									<label class="form-check-label" for="tagColors"> | ||||
| 										Use auto-generated tag colors | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input | ||||
| 										id="htmlCheck" | ||||
| 										v-model="mailbox.showHTMLCheck" | ||||
| 										class="form-check-input" | ||||
| 										type="checkbox" | ||||
| 										role="switch" | ||||
| 									/> | ||||
| 									<label class="form-check-label" for="htmlCheck"> | ||||
| 										Show HTML check message tab | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input | ||||
| 										id="linkCheck" | ||||
| 										v-model="mailbox.showLinkCheck" | ||||
| 										class="form-check-input" | ||||
| 										type="checkbox" | ||||
| 										role="switch" | ||||
| 									/> | ||||
| 									<label class="form-check-label" for="linkCheck"> | ||||
| 										Show link check message tab | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div v-if="mailbox.uiConfig.SpamAssassin" class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input | ||||
| 										id="spamCheck" | ||||
| 										v-model="mailbox.showSpamCheck" | ||||
| 										class="form-check-input" | ||||
| 										type="checkbox" | ||||
| 										role="switch" | ||||
| 									/> | ||||
| 									<label class="form-check-label" for="spamCheck"> | ||||
| 										Show spam check message tab | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input | ||||
| 										id="skip-confirmations" | ||||
| 										v-model="mailbox.skipConfirmations" | ||||
| 										class="form-check-input" | ||||
| 										type="checkbox" | ||||
| 										role="switch" | ||||
| 									/> | ||||
| 									<label class="form-check-label" for="skip-confirmations"> | ||||
| 										Skip | ||||
| 										<template v-if="!mailbox.uiConfig.HideDeleteAllButton"> | ||||
| 											<code>Delete all</code> & | ||||
| 										</template> | ||||
| 										<code>Mark all read</code> confirmation dialogs | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
|  | ||||
| 						<div | ||||
| 							v-if="mailbox.uiConfig.ChaosEnabled" | ||||
| 							id="chaos-tab-pane" | ||||
| 							class="tab-pane fade" | ||||
| 							role="tabpanel" | ||||
| 							aria-labelledby="chaos-tab" | ||||
| 							tabindex="0" | ||||
| 						> | ||||
| 							<p class="my-3"> | ||||
| 								<b>Chaos</b> allows you to set random SMTP failures and response codes at various stages | ||||
| 								in a SMTP transaction to test application resilience (<a | ||||
| 									href="https://mailpit.axllent.org/docs/integration/chaos/" | ||||
| 									target="_blank" | ||||
| 								> | ||||
| 									see documentation </a | ||||
| 								>). | ||||
| 							</p> | ||||
|  | ||||
| 							<ul> | ||||
| 								<li> | ||||
| 									<code>Response code</code> is the SMTP error code returned by the server if this | ||||
| 									error is triggered. Error codes must range between 400 and 599. | ||||
| 								</li> | ||||
| 								<li> | ||||
| 									<code>Error probability</code> is the % chance that the error will occur per message | ||||
| 									delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always | ||||
| 									trigger. A probability of <code>50</code> will trigger on approximately 50% of | ||||
| 									messages received. | ||||
| 								</li> | ||||
| 							</ul> | ||||
|  | ||||
| 							<template v-if="chaosConfig"> | ||||
| 								<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''"> | ||||
| 									<div class="mb-4"> | ||||
| 										<label>Trigger: <code>Sender</code></label> | ||||
| 										<div class="form-text"> | ||||
| 											Trigger an error response based on the sender (From / Sender). | ||||
| 										</div> | ||||
| 										<div class="row mt-1"> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> Response code </label> | ||||
| 												<input | ||||
| 													v-model.number="chaosConfig.Sender.ErrorCode" | ||||
| 													type="number" | ||||
| 													class="form-control" | ||||
| 													min="400" | ||||
| 													max="599" | ||||
| 													required | ||||
| 												/> | ||||
| 											</div> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Error probability ({{ chaosConfig.Sender.Probability }}%) | ||||
| 												</label> | ||||
| 												<input | ||||
| 													v-model.number="chaosConfig.Sender.Probability" | ||||
| 													type="range" | ||||
| 													class="form-range mt-1" | ||||
| 													min="0" | ||||
| 													max="100" | ||||
| 												/> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
|  | ||||
| 									<div class="mb-4"> | ||||
| 										<label>Trigger: <code>Recipient</code></label> | ||||
| 										<div class="form-text"> | ||||
| 											Trigger an error response based on the recipients (To, Cc, Bcc). | ||||
| 										</div> | ||||
| 										<div class="row mt-1"> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> Response code </label> | ||||
| 												<input | ||||
| 													v-model.number="chaosConfig.Recipient.ErrorCode" | ||||
| 													type="number" | ||||
| 													class="form-control" | ||||
| 													min="400" | ||||
| 													max="599" | ||||
| 													required | ||||
| 												/> | ||||
| 											</div> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Error probability ({{ chaosConfig.Recipient.Probability }}%) | ||||
| 												</label> | ||||
| 												<input | ||||
| 													v-model.number="chaosConfig.Recipient.Probability" | ||||
| 													type="range" | ||||
| 													class="form-range mt-1" | ||||
| 													min="0" | ||||
| 													max="100" | ||||
| 												/> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
|  | ||||
| 									<div class="mb-4"> | ||||
| 										<label>Trigger: <code>Authentication</code></label> | ||||
| 										<div class="form-text"> | ||||
| 											Trigger an authentication error response. Note that SMTP authentication must | ||||
| 											be configured too. | ||||
| 										</div> | ||||
| 										<div class="row mt-1"> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> Response code </label> | ||||
| 												<input | ||||
| 													v-model.number="chaosConfig.Authentication.ErrorCode" | ||||
| 													type="number" | ||||
| 													class="form-control" | ||||
| 													min="400" | ||||
| 													max="599" | ||||
| 													required | ||||
| 												/> | ||||
| 											</div> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Error probability ({{ chaosConfig.Authentication.Probability }}%) | ||||
| 												</label> | ||||
| 												<input | ||||
| 													v-model.number="chaosConfig.Authentication.Probability" | ||||
| 													type="range" | ||||
| 													class="form-range mt-1" | ||||
| 													min="0" | ||||
| 													max="100" | ||||
| 												/> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
|  | ||||
| 								<div v-if="chaosUpdated" class="mb-3 text-center"> | ||||
| 									<button class="btn btn-success" @click="saveChaos">Update Chaos</button> | ||||
| 								</div> | ||||
| 							</template> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
| @@ -1,6 +1,6 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| @@ -9,74 +9,83 @@ export default { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			editableTags: [], | ||||
| 			validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/), | ||||
| 			validTagRe: /^([a-zA-Z0-9\- ._]){1,}$/, | ||||
| 			tagToDelete: false, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		'mailbox.tags': { | ||||
| 		"mailbox.tags": { | ||||
| 			handler(tags) { | ||||
| 				this.editableTags = [] | ||||
| 				this.editableTags = []; | ||||
| 				tags.forEach((t) => { | ||||
| 					this.editableTags.push({ before: t, after: t }) | ||||
| 				}) | ||||
| 					this.editableTags.push({ before: t, after: t }); | ||||
| 				}); | ||||
| 			}, | ||||
| 			deep: true | ||||
| 		} | ||||
| 			deep: true, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		validTag(t) { | ||||
| 			if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) { | ||||
| 				return false | ||||
| 			if (!t.after.match(/^([a-zA-Z0-9\- _.]){1,}$/)) { | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			const lower = t.after.toLowerCase() | ||||
| 			const lower = t.after.toLowerCase(); | ||||
| 			for (let x = 0; x < this.editableTags.length; x++) { | ||||
| 				if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) { | ||||
| 					return false | ||||
| 				if (this.editableTags[x].before !== t.before && lower === this.editableTags[x].before.toLowerCase()) { | ||||
| 					return false; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return true | ||||
| 			return true; | ||||
| 		}, | ||||
|  | ||||
| 		renameTag(t) { | ||||
| 			if (!this.validTag(t) || t.before == t.after) { | ||||
| 				return | ||||
| 			if (!this.validTag(t) || t.before === t.after) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => { | ||||
| 				// the API triggers a reload via websockets | ||||
| 			}) | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		deleteTag() { | ||||
| 			this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => { | ||||
| 				// the API triggers a reload via websockets | ||||
| 				this.tagToDelete = false | ||||
| 			}) | ||||
| 				this.tagToDelete = false; | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		resetTagEdit(t) { | ||||
| 			for (let x = 0; x < this.editableTags.length; x++) { | ||||
| 				if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) { | ||||
| 					this.editableTags[x].after = this.editableTags[x].before | ||||
| 				if ( | ||||
| 					this.editableTags[x].before !== t.before && | ||||
| 					this.editableTags[x].before !== this.editableTags[x].after | ||||
| 				) { | ||||
| 					this.editableTags[x].after = this.editableTags[x].before; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true" | ||||
| 		data-bs-keyboard="false"> | ||||
| 	<div | ||||
| 		id="EditTagsModal" | ||||
| 		class="modal fade" | ||||
| 		tabindex="-1" | ||||
| 		aria-labelledby="EditTagsModalLabel" | ||||
| 		aria-hidden="true" | ||||
| 		data-bs-keyboard="false" | ||||
| 	> | ||||
| 		<div class="modal-dialog modal-lg"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5> | ||||
| 					<h5 id="EditTagsModalLabel" class="modal-title">Edit tags</h5> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| @@ -84,29 +93,34 @@ export default { | ||||
| 						Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag | ||||
| 						itself, and not any messages which had the tag. | ||||
| 					</p> | ||||
| 					<div class="mb-3" v-for="t in editableTags"> | ||||
| 					<div v-for="(t, i) in editableTags" :key="'tag_' + i" class="mb-3"> | ||||
| 						<div class="input-group has-validation"> | ||||
| 							<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''" | ||||
| 								v-model.trim="t.after" aria-describedby="inputGroupPrepend" required | ||||
| 								@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before" | ||||
| 								@focus="resetTagEdit(t)"> | ||||
| 							<button v-if="t.before != t.after" class="btn btn-success" | ||||
| 								@click="renameTag(t)">Save</button> | ||||
| 							<input | ||||
| 								v-model.trim="t.after" | ||||
| 								type="text" | ||||
| 								class="form-control" | ||||
| 								:class="!validTag(t) ? 'is-invalid' : ''" | ||||
| 								aria-describedby="inputGroupPrepend" | ||||
| 								required | ||||
| 								@keydown.enter="renameTag(t)" | ||||
| 								@keydown.esc="t.after = t.before" | ||||
| 								@focus="resetTagEdit(t)" | ||||
| 							/> | ||||
| 							<button v-if="t.before != t.after" class="btn btn-success" @click="renameTag(t)"> | ||||
| 								Save | ||||
| 							</button> | ||||
| 							<template v-else> | ||||
| 								<button class="btn btn-outline-danger" | ||||
| 								<button | ||||
| 									class="btn btn-outline-danger" | ||||
| 									:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''" | ||||
| 									@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false"> | ||||
| 									<template v-if="tagToDelete == t"> | ||||
| 										Confirm? | ||||
| 									</template> | ||||
| 									<template v-else> | ||||
| 										Delete | ||||
| 									</template> | ||||
| 									@click="!tagToDelete ? (tagToDelete = t) : deleteTag()" | ||||
| 									@blur="tagToDelete = false" | ||||
| 								> | ||||
| 									<template v-if="tagToDelete == t"> Confirm? </template> | ||||
| 									<template v-else> Delete </template> | ||||
| 								</button> | ||||
| 							</template> | ||||
| 							<div class="invalid-feedback"> | ||||
| 								Invalid tag name | ||||
| 							</div> | ||||
| 							<div class="invalid-feedback">Invalid tag name</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
|   | ||||
| @@ -1,122 +0,0 @@ | ||||
| <script> | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
|  | ||||
| export default { | ||||
|     data() { | ||||
|         return { | ||||
|             favicon: false, | ||||
|             iconPath: false, | ||||
|             iconTextColor: '#ffffff', | ||||
|             iconBgColor: '#dd0000', | ||||
|             iconFontSize: 40, | ||||
|             iconProcessing: false, | ||||
|             iconTimeout: 500, | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     mounted() { | ||||
|         this.favicon = document.head.querySelector('link[rel="icon"]') | ||||
|         if (this.favicon) { | ||||
|             this.iconPath = this.favicon.href | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     computed: { | ||||
|         count() { | ||||
|             let i = mailbox.unread | ||||
|             if (i > 1000) { | ||||
|                 i = Math.floor(i / 1000) + 'k' | ||||
|             } | ||||
|  | ||||
|             return i | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     watch: { | ||||
|         count() { | ||||
|             if (!this.favicon || this.iconProcessing) { | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             this.iconProcessing = true | ||||
|  | ||||
|             window.setTimeout(() => { | ||||
|                 this.icoUpdate() | ||||
|             }, this.iconTimeout) | ||||
|         }, | ||||
|     }, | ||||
|  | ||||
|     methods: { | ||||
|         async icoUpdate() { | ||||
|             if (!this.favicon) { | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             if (!this.count) { | ||||
|                 this.iconProcessing = false | ||||
|                 this.favicon.href = this.iconPath | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             let fontSize = this.iconFontSize | ||||
|             // Draw badge text | ||||
|             let textPaddingX = 7 | ||||
|             let textPaddingY = 3 | ||||
|  | ||||
|             let strlen = this.count.toString().length | ||||
|  | ||||
|             if (strlen > 2) { | ||||
|                 // if text >= 3 characters then reduce size and padding | ||||
|                 textPaddingX = 4 | ||||
|                 fontSize = strlen > 3 ? 30 : 36 | ||||
|             } | ||||
|  | ||||
|             let canvas = document.createElement('canvas') | ||||
|             canvas.width = 64 | ||||
|             canvas.height = 64 | ||||
|  | ||||
|             let ctx = canvas.getContext('2d') | ||||
|  | ||||
|             // Draw base icon | ||||
|             let icon = new Image() | ||||
|             icon.src = this.iconPath | ||||
|             await icon.decode() | ||||
|  | ||||
|             ctx.drawImage(icon, 0, 0, 64, 64) | ||||
|  | ||||
|             // Measure text | ||||
|             ctx.font = `${fontSize}px Arial, sans-serif` | ||||
|             ctx.textAlign = 'right' | ||||
|             ctx.textBaseline = 'top' | ||||
|             let textMetrics = ctx.measureText(this.count) | ||||
|  | ||||
|             // Draw badge | ||||
|             let paddingX = 7 | ||||
|             let paddingY = 4 | ||||
|             let cornerRadius = 8 | ||||
|  | ||||
|             let width = textMetrics.width + paddingX * 2 | ||||
|             let height = fontSize + paddingY * 2 | ||||
|             let x = canvas.width - width | ||||
|             let y = canvas.height - height - 1 | ||||
|  | ||||
|             ctx.fillStyle = this.iconBgColor | ||||
|             ctx.roundRect(x, y, width, height, cornerRadius) | ||||
|             ctx.fill() | ||||
|  | ||||
|             ctx.fillStyle = this.iconTextColor | ||||
|             ctx.fillText( | ||||
|                 this.count, | ||||
|                 canvas.width - textPaddingX, | ||||
|                 canvas.height - fontSize - textPaddingY | ||||
|             ) | ||||
|  | ||||
|             this.iconProcessing = false | ||||
|  | ||||
|             this.favicon.href = canvas.toDataURL("image/png") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template></template> | ||||
| @@ -1,135 +1,142 @@ | ||||
| <script> | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import dayjs from 'dayjs' | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import dayjs from "dayjs"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [ | ||||
| 		CommonMixins | ||||
| 	], | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins | ||||
| 		// use different name to `loading` as that is already in use in CommonMixins | ||||
| 		loadingMessages: { | ||||
| 			type: Number, | ||||
| 			default: 0, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	created() { | ||||
| 		const relativeTime = require('dayjs/plugin/relativeTime') | ||||
| 		dayjs.extend(relativeTime) | ||||
| 		const relativeTime = require("dayjs/plugin/relativeTime"); | ||||
| 		dayjs.extend(relativeTime); | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.refreshUI() | ||||
| 		this.refreshUI(); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		refreshUI() { | ||||
| 			window.setTimeout(() => { | ||||
| 				this.$forceUpdate() | ||||
| 				this.refreshUI() | ||||
| 			}, 30000) | ||||
| 				this.$forceUpdate(); | ||||
| 				this.refreshUI(); | ||||
| 			}, 30000); | ||||
| 		}, | ||||
|  | ||||
| 		getRelativeCreated(message) { | ||||
| 			const d = new Date(message.Created) | ||||
| 			return dayjs(d).fromNow() | ||||
| 			const d = new Date(message.Created); | ||||
| 			return dayjs(d).fromNow(); | ||||
| 		}, | ||||
|  | ||||
| 		getPrimaryEmailTo(message) { | ||||
| 			for (let i in message.To) { | ||||
| 				return message.To[i].Address | ||||
| 			if (message.To && message.To.length > 0) { | ||||
| 				return message.To[0].Address; | ||||
| 			} | ||||
|  | ||||
| 			return '[ Undisclosed recipients ]' | ||||
| 			return "[ Undisclosed recipients ]"; | ||||
| 		}, | ||||
|  | ||||
| 		isSelected(id) { | ||||
| 			return mailbox.selected.indexOf(id) != -1 | ||||
| 			return mailbox.selected.indexOf(id) !== -1; | ||||
| 		}, | ||||
|  | ||||
| 		toggleSelected(e, id) { | ||||
| 			e.preventDefault() | ||||
| 			e.preventDefault(); | ||||
|  | ||||
| 			if (this.isSelected(id)) { | ||||
| 				mailbox.selected = mailbox.selected.filter(function (ele) { | ||||
| 					return ele != id | ||||
| 				}) | ||||
| 				mailbox.selected = mailbox.selected.filter((ele) => { | ||||
| 					return ele !== id; | ||||
| 				}); | ||||
| 			} else { | ||||
| 				mailbox.selected.push(id) | ||||
| 				mailbox.selected.push(id); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		selectRange(e, id) { | ||||
| 			e.preventDefault() | ||||
| 			e.preventDefault(); | ||||
|  | ||||
| 			let selecting = false | ||||
| 			let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1] | ||||
| 			if (lastSelected == id) { | ||||
| 				mailbox.selected = mailbox.selected.filter(function (ele) { | ||||
| 					return ele != id | ||||
| 				}) | ||||
| 				return | ||||
| 			let selecting = false; | ||||
| 			const lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]; | ||||
| 			if (lastSelected === id) { | ||||
| 				mailbox.selected = mailbox.selected.filter((ele) => { | ||||
| 					return ele !== id; | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			if (lastSelected === false) { | ||||
| 				mailbox.selected.push(id) | ||||
| 				return | ||||
| 				mailbox.selected.push(id); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			for (let d of mailbox.messages) { | ||||
| 			for (const d of mailbox.messages) { | ||||
| 				if (selecting) { | ||||
| 					if (!this.isSelected(d.ID)) { | ||||
| 						mailbox.selected.push(d.ID) | ||||
| 						mailbox.selected.push(d.ID); | ||||
| 					} | ||||
| 					if (d.ID == lastSelected || d.ID == id) { | ||||
| 					if (d.ID === lastSelected || d.ID === id) { | ||||
| 						// reached backwards select | ||||
| 						break | ||||
| 						break; | ||||
| 					} | ||||
| 				} else if (d.ID == id || d.ID == lastSelected) { | ||||
| 				} else if (d.ID === id || d.ID === lastSelected) { | ||||
| 					if (!this.isSelected(d.ID)) { | ||||
| 						mailbox.selected.push(d.ID) | ||||
| 						mailbox.selected.push(d.ID); | ||||
| 					} | ||||
| 					selecting = true | ||||
| 					selecting = true; | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		toTagUrl(t) { | ||||
| 			if (t.match(/ /)) { | ||||
| 				t = `"${t}"` | ||||
| 				t = `"${t}"`; | ||||
| 			} | ||||
| 			const p = { | ||||
| 				q: 'tag:' + t | ||||
| 				q: "tag:" + t, | ||||
| 			}; | ||||
| 			if (pagination.limit !== pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString(); | ||||
| 			} | ||||
| 			if (pagination.limit != pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString() | ||||
| 			} | ||||
| 			const params = new URLSearchParams(p) | ||||
| 			return '/search?' + params.toString() | ||||
| 			const params = new URLSearchParams(p); | ||||
| 			return "/search?" + params.toString(); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<template v-if="mailbox.messages && mailbox.messages.length"> | ||||
| 		<div class="list-group my-2"> | ||||
| 			<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID" | ||||
| 			<RouterLink | ||||
| 				v-for="message in mailbox.messages" | ||||
| 				:id="message.ID" | ||||
| 				:key="'message_' + message.ID" | ||||
| 				:to="'/view/' + message.ID" | ||||
| 				class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0" | ||||
| 				:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''" | ||||
| 				@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)" | ||||
| 				@click.shift="selectRange($event, message.ID)"> | ||||
| 				:class="[message.Read ? 'read' : '', isSelected(message.ID) ? ' selected' : '']" | ||||
| 				@click.meta="toggleSelected($event, message.ID)" | ||||
| 				@click.ctrl="toggleSelected($event, message.ID)" | ||||
| 				@click.shift="selectRange($event, message.ID)" | ||||
| 			> | ||||
| 				<div class="col-lg-3"> | ||||
| 					<div class="d-lg-none float-end text-muted text-nowrap small"> | ||||
| 						<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i> | ||||
| 						<i v-if="message.Attachments" class="bi bi-paperclip h6 me-1"></i> | ||||
| 						{{ getRelativeCreated(message) }} | ||||
| 					</div> | ||||
| 					<div v-if="message.From" class="overflow-x-hidden"> | ||||
| @@ -142,30 +149,37 @@ export default { | ||||
| 					<div class="overflow-x-hidden"> | ||||
| 						<div class="text-truncate text-muted small privacy"> | ||||
| 							To: {{ getPrimaryEmailTo(message) }} | ||||
| 							<span v-if="message.To && message.To.length > 1"> | ||||
| 								[+{{ message.To.length - 1 }}] | ||||
| 							</span> | ||||
| 							<span v-if="message.To && message.To.length > 1"> [+{{ message.To.length - 1 }}] </span> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0"> | ||||
| 					<div class="subject text-truncate text-spaces-nowrap"> | ||||
| 						<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b> | ||||
| 						<b>{{ message.Subject !== "" ? message.Subject : "[ no subject ]" }}</b> | ||||
| 					</div> | ||||
| 					<div v-if="message.Snippet != ''" class="small text-muted text-truncate"> | ||||
| 					<div v-if="message.Snippet !== ''" class="small text-muted text-truncate"> | ||||
| 						{{ message.Snippet }} | ||||
| 					</div> | ||||
| 					<div v-if="message.Tags.length"> | ||||
| 						<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)" | ||||
| 							v-on:click="pagination.start = 0" | ||||
| 							:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }" | ||||
| 							:title="'Filter messages tagged with ' + t"> | ||||
| 						<RouterLink | ||||
| 							v-for="t in message.Tags" | ||||
| 							:key="t" | ||||
| 							class="badge me-1" | ||||
| 							:to="toTagUrl(t)" | ||||
| 							:style=" | ||||
| 								mailbox.showTagColors | ||||
| 									? { backgroundColor: colorHash(t) } | ||||
| 									: { backgroundColor: '#6c757d' } | ||||
| 							" | ||||
| 							:title="'Filter messages tagged with ' + t" | ||||
| 							@click="pagination.start = 0" | ||||
| 						> | ||||
| 							{{ t }} | ||||
| 						</RouterLink> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="d-none d-lg-block col-1 small text-end text-muted"> | ||||
| 					<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i> | ||||
| 					<i v-if="message.Attachments" class="bi bi-paperclip float-start h6"></i> | ||||
| 					{{ getFileSize(message.Size) }} | ||||
| 				</div> | ||||
| 				<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted"> | ||||
| @@ -176,10 +190,10 @@ export default { | ||||
| 	</template> | ||||
| 	<template v-else> | ||||
| 		<p class="text-center mt-5"> | ||||
| 			<span v-if="loadingMessages > 0" class="text-muted"> | ||||
| 				Loading messages... | ||||
| 			</span> | ||||
| 			<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template> | ||||
| 			<span v-if="loadingMessages > 0" class="text-muted"> Loading messages... </span> | ||||
| 			<template v-else-if="getSearch()" | ||||
| 				>No results for <code>{{ getSearch() }}</code></template | ||||
| 			> | ||||
| 			<template v-else>No messages in your mailbox</template> | ||||
| 		</p> | ||||
| 	</template> | ||||
|   | ||||
| @@ -1,156 +1,193 @@ | ||||
| <script> | ||||
| import NavSelected from '../components/NavSelected.vue' | ||||
| import AjaxLoader from "./AjaxLoader.vue" | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
| import NavSelected from "../components/NavSelected.vue"; | ||||
| import AjaxLoader from "./AjaxLoader.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		NavSelected, | ||||
| 		AjaxLoader, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		modals: { | ||||
| 			type: Boolean, | ||||
| 			default: false, | ||||
| 		} | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
| 	emits: ["loadMessages"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		reloadInbox() { | ||||
| 			const paginationParams = this.getPaginationParams() | ||||
| 			const reload = paginationParams?.start ? false : true | ||||
| 			const paginationParams = this.getPaginationParams(); | ||||
| 			const reload = !paginationParams?.start; | ||||
|  | ||||
| 			this.$router.push('/') | ||||
| 			this.$router.push("/"); | ||||
| 			if (reload) { | ||||
| 				// already on first page, reload messages | ||||
| 				this.loadMessages() | ||||
| 				this.loadMessages(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		loadMessages() { | ||||
| 			this.hideNav() // hide mobile menu | ||||
| 			this.$emit('loadMessages') | ||||
| 			this.hideNav(); // hide mobile menu | ||||
| 			this.$emit("loadMessages"); | ||||
| 		}, | ||||
|  | ||||
| 		markAllRead() { | ||||
| 			this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => { | ||||
| 				window.scrollInPlace = true | ||||
| 				this.loadMessages() | ||||
| 			}) | ||||
| 			this.put(this.resolve(`/api/v1/messages`), { read: true }, (response) => { | ||||
| 				window.scrollInPlace = true; | ||||
| 				this.loadMessages(); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		deleteAllMessages() { | ||||
| 			this.delete(this.resolve(`/api/v1/messages`), false, (response) => { | ||||
| 				pagination.start = 0 | ||||
| 				this.loadMessages() | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 				pagination.start = 0; | ||||
| 				this.loadMessages(); | ||||
| 			}); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<template v-if="!modals"> | ||||
| 		<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label"> | ||||
| 		<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100"> | ||||
| 			<div class="text-truncate fw-normal" style="line-height: 1rem"> | ||||
| 				{{ mailbox.uiConfig.Label }} | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''"> | ||||
| 			<button @click="reloadInbox" class="list-group-item list-group-item-action active"> | ||||
| 				<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i> | ||||
| 				<i class="bi bi-arrow-clockwise me-1" v-else></i> | ||||
| 			<button class="list-group-item list-group-item-action active" @click="reloadInbox"> | ||||
| 				<i v-if="mailbox.connected" class="bi bi-envelope-fill me-1"></i> | ||||
| 				<i v-else class="bi bi-arrow-clockwise me-1"></i> | ||||
| 				<span class="ms-1">Inbox</span> | ||||
| 				<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages" | ||||
| 					v-if="mailbox.unread"> | ||||
| 				<span | ||||
| 					v-if="mailbox.unread" | ||||
| 					class="badge rounded-pill ms-1 float-end text-bg-secondary" | ||||
| 					title="Unread messages" | ||||
| 				> | ||||
| 					{{ formatNumber(mailbox.unread) }} | ||||
| 				</span> | ||||
| 			</button> | ||||
|  | ||||
| 			<template v-if="!mailbox.selected.length"> | ||||
| 				<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" | ||||
| 					:disabled="!mailbox.messages_unread" @click="markAllRead"> | ||||
| 				<button | ||||
| 					v-if="mailbox.skipConfirmations" | ||||
| 					class="list-group-item list-group-item-action" | ||||
| 					:disabled="!mailbox.messages_unread" | ||||
| 					@click="markAllRead" | ||||
| 				> | ||||
| 					<i class="bi bi-eye-fill me-1"></i> | ||||
| 					Mark all read | ||||
| 				</button> | ||||
| 				<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" | ||||
| 					data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread"> | ||||
| 				<button | ||||
| 					v-else | ||||
| 					class="list-group-item list-group-item-action" | ||||
| 					data-bs-toggle="modal" | ||||
| 					data-bs-target="#MarkAllReadModal" | ||||
| 					:disabled="!mailbox.messages_unread" | ||||
| 				> | ||||
| 					<i class="bi bi-eye-fill me-1"></i> | ||||
| 					Mark all read | ||||
| 				</button> | ||||
| 				<!-- checking if MessageRelay is defined prevents UI flicker while loading --> | ||||
| 				<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton"> | ||||
| 					<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" | ||||
| 						:disabled="!mailbox.total" @click="deleteAllMessages"> | ||||
| 					<button | ||||
| 						v-if="mailbox.skipConfirmations" | ||||
| 						class="list-group-item list-group-item-action" | ||||
| 						:disabled="!mailbox.total" | ||||
| 						@click="deleteAllMessages" | ||||
| 					> | ||||
| 						<i class="bi bi-trash-fill me-1 text-danger"></i> | ||||
| 						Delete all | ||||
| 					</button> | ||||
| 					<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" | ||||
| 						data-bs-target="#DeleteAllModal" :disabled="!mailbox.total"> | ||||
| 					<button | ||||
| 						v-else | ||||
| 						class="list-group-item list-group-item-action" | ||||
| 						data-bs-toggle="modal" | ||||
| 						data-bs-target="#DeleteAllModal" | ||||
| 						:disabled="!mailbox.total" | ||||
| 					> | ||||
| 						<i class="bi bi-trash-fill me-1 text-danger"></i> | ||||
| 						Delete all | ||||
| 					</button> | ||||
| 				</template> | ||||
| 			</template> | ||||
|  | ||||
| 			<NavSelected @loadMessages="loadMessages" /> | ||||
| 			<NavSelected @load-messages="loadMessages" /> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template v-else> | ||||
| 		<!-- Modals --> | ||||
| 		<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" | ||||
| 			aria-hidden="true"> | ||||
| 		<div | ||||
| 			id="MarkAllReadModal" | ||||
| 			class="modal fade" | ||||
| 			tabindex="-1" | ||||
| 			aria-labelledby="MarkAllReadModalLabel" | ||||
| 			aria-hidden="true" | ||||
| 		> | ||||
| 			<div class="modal-dialog"> | ||||
| 				<div class="modal-content"> | ||||
| 					<div class="modal-header"> | ||||
| 						<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5> | ||||
| 						<h5 id="MarkAllReadModalLabel" class="modal-title">Mark all messages as read?</h5> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						This will mark {{ formatNumber(mailbox.unread) }} | ||||
| 						message<span v-if="mailbox.unread > 1">s</span> as read. | ||||
| 						This will mark {{ formatNumber(mailbox.unread) }} message<span v-if="mailbox.unread > 1" | ||||
| 							>s</span | ||||
| 						> | ||||
| 						as read. | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | ||||
| 						<button type="button" class="btn btn-success" data-bs-dismiss="modal" | ||||
| 							v-on:click="markAllRead">Confirm</button> | ||||
| 						<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead"> | ||||
| 							Confirm | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" | ||||
| 			aria-hidden="true"> | ||||
| 		<div | ||||
| 			id="DeleteAllModal" | ||||
| 			class="modal fade" | ||||
| 			tabindex="-1" | ||||
| 			aria-labelledby="DeleteAllModalLabel" | ||||
| 			aria-hidden="true" | ||||
| 		> | ||||
| 			<div class="modal-dialog"> | ||||
| 				<div class="modal-content"> | ||||
| 					<div class="modal-header"> | ||||
| 						<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5> | ||||
| 						<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages?</h5> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						This will permanently delete {{ formatNumber(mailbox.total) }} | ||||
| 						message<span v-if="mailbox.total > 1">s</span>. | ||||
| 						This will permanently delete {{ formatNumber(mailbox.total) }} message<span | ||||
| 							v-if="mailbox.total > 1" | ||||
| 							>s</span | ||||
| 						>. | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | ||||
| 						<button type="button" class="btn btn-danger" data-bs-dismiss="modal" | ||||
| 							v-on:click="deleteAllMessages">Delete</button> | ||||
| 						<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages"> | ||||
| 							Delete | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
|   | ||||
							
								
								
									
										120
									
								
								server/ui-src/components/NavPagination.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								server/ui-src/components/NavPagination.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| <script> | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { limitOptions, pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		total: { | ||||
| 			type: Number, | ||||
| 			default: 0, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			pagination, | ||||
| 			mailbox, | ||||
| 			limitOptions, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		canPrev() { | ||||
| 			return pagination.start > 0; | ||||
| 		}, | ||||
|  | ||||
| 		canNext() { | ||||
| 			return this.total > pagination.start + mailbox.messages.length; | ||||
| 		}, | ||||
|  | ||||
| 		// returns the number of next X messages | ||||
| 		nextMessages() { | ||||
| 			let t = pagination.start + parseInt(pagination.limit, 10); | ||||
| 			if (t > this.total) { | ||||
| 				t = this.total; | ||||
| 			} | ||||
|  | ||||
| 			return t; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		changeLimit() { | ||||
| 			pagination.start = 0; | ||||
| 			this.updateQueryParams(); | ||||
| 		}, | ||||
|  | ||||
| 		viewNext() { | ||||
| 			pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10); | ||||
| 			this.updateQueryParams(); | ||||
| 		}, | ||||
|  | ||||
| 		viewPrev() { | ||||
| 			let s = pagination.start - pagination.limit; | ||||
| 			if (s < 0) { | ||||
| 				s = 0; | ||||
| 			} | ||||
| 			pagination.start = s; | ||||
| 			this.updateQueryParams(); | ||||
| 		}, | ||||
|  | ||||
| 		updateQueryParams() { | ||||
| 			const path = this.$route.path; | ||||
| 			const p = { | ||||
| 				...this.$route.query, | ||||
| 			}; | ||||
| 			if (pagination.start > 0) { | ||||
| 				p.start = pagination.start.toString(); | ||||
| 			} else { | ||||
| 				delete p.start; | ||||
| 			} | ||||
| 			if (pagination.limit !== pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString(); | ||||
| 			} else { | ||||
| 				delete p.limit; | ||||
| 			} | ||||
| 			const params = new URLSearchParams(p); | ||||
| 			this.$router.push(path + "?" + params.toString()); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
| 	<select | ||||
| 		v-model="pagination.limit" | ||||
| 		class="form-select form-select-sm d-inline w-auto me-2" | ||||
| 		:disabled="total == 0" | ||||
| 		@change="changeLimit" | ||||
| 	> | ||||
| 		<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option> | ||||
| 	</select> | ||||
|  | ||||
| 	<small> | ||||
| 		<template v-if="total > 0"> | ||||
| 			{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }} | ||||
| 			<small>of</small> | ||||
| 			{{ formatNumber(total) }} | ||||
| 		</template> | ||||
| 		<span v-else class="text-muted">0 of 0</span> | ||||
| 	</small> | ||||
|  | ||||
| 	<button | ||||
| 		class="btn btn-outline-light ms-2 me-1" | ||||
| 		:disabled="!canPrev" | ||||
| 		:title="'View previous ' + pagination.limit + ' messages'" | ||||
| 		@click="viewPrev" | ||||
| 	> | ||||
| 		<i class="bi bi-caret-left-fill"></i> | ||||
| 	</button> | ||||
| 	<button | ||||
| 		class="btn btn-outline-light" | ||||
| 		:disabled="!canNext" | ||||
| 		:title="'View next ' + pagination.limit + ' messages'" | ||||
| 		@click="viewNext" | ||||
| 	> | ||||
| 		<i class="bi bi-caret-right-fill"></i> | ||||
| 	</button> | ||||
| </template> | ||||
| @@ -1,79 +1,79 @@ | ||||
| <script> | ||||
| import NavSelected from '../components/NavSelected.vue' | ||||
| import AjaxLoader from './AjaxLoader.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
| import NavSelected from "../components/NavSelected.vue"; | ||||
| import AjaxLoader from "./AjaxLoader.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		NavSelected, | ||||
| 		AjaxLoader, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		modals: { | ||||
| 			type: Boolean, | ||||
| 			default: false, | ||||
| 		} | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
| 	emits: ["loadMessages"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		loadMessages() { | ||||
| 			this.hideNav() // hide mobile menu | ||||
| 			this.$emit('loadMessages') | ||||
| 			this.hideNav(); // hide mobile menu | ||||
| 			this.$emit("loadMessages"); | ||||
| 		}, | ||||
|  | ||||
| 		deleteAllMessages() { | ||||
| 			const s = this.getSearch() | ||||
| 			const s = this.getSearch(); | ||||
| 			if (!s) { | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s) | ||||
| 			if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) { | ||||
| 				uri += '&tz=' + encodeURIComponent(mailbox.timeZone) | ||||
| 			let uri = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s); | ||||
| 			if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) { | ||||
| 				uri += "&tz=" + encodeURIComponent(mailbox.timeZone); | ||||
| 			} | ||||
|  | ||||
| 			this.delete(uri, false, () => { | ||||
| 				this.$router.push('/') | ||||
| 			}) | ||||
| 				this.$router.push("/"); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		markAllRead() { | ||||
| 			const s = this.getSearch() | ||||
| 			const s = this.getSearch(); | ||||
| 			if (!s) { | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			let uri = this.resolve(`/api/v1/messages`) | ||||
| 			if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) { | ||||
| 				uri += '?tz=' + encodeURIComponent(mailbox.timeZone) | ||||
| 			let uri = this.resolve(`/api/v1/messages`); | ||||
| 			if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) { | ||||
| 				uri += "?tz=" + encodeURIComponent(mailbox.timeZone); | ||||
| 			} | ||||
|  | ||||
| 			this.put(uri, { 'read': true, "search": s }, () => { | ||||
| 				window.scrollInPlace = true | ||||
| 				this.loadMessages() | ||||
| 			}) | ||||
| 			this.put(uri, { read: true, search: s }, () => { | ||||
| 				window.scrollInPlace = true; | ||||
| 				this.loadMessages(); | ||||
| 			}); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<template v-if="!modals"> | ||||
| 		<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label"> | ||||
| 		<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100"> | ||||
| 			<div class="text-truncate fw-normal" style="line-height: 1rem"> | ||||
| 				{{ mailbox.uiConfig.Label }} | ||||
| 			</div> | ||||
| @@ -83,83 +83,121 @@ export default { | ||||
| 			<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0"> | ||||
| 				<i class="bi bi-arrow-return-left me-1"></i> | ||||
| 				<span class="ms-1">Inbox</span> | ||||
| 				<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages" | ||||
| 					v-if="mailbox.unread"> | ||||
| 				<span | ||||
| 					v-if="mailbox.unread" | ||||
| 					class="badge rounded-pill ms-1 float-end text-bg-secondary" | ||||
| 					title="Unread messages" | ||||
| 				> | ||||
| 					{{ formatNumber(mailbox.unread) }} | ||||
| 				</span> | ||||
| 			</RouterLink> | ||||
| 			<template v-if="!mailbox.selected.length"> | ||||
| 				<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" | ||||
| 					:disabled="!mailbox.messages_unread" @click="markAllRead"> | ||||
| 				<button | ||||
| 					v-if="mailbox.skipConfirmations" | ||||
| 					class="list-group-item list-group-item-action" | ||||
| 					:disabled="!mailbox.messages_unread" | ||||
| 					@click="markAllRead" | ||||
| 				> | ||||
| 					<i class="bi bi-eye-fill me-1"></i> | ||||
| 					Mark all read | ||||
| 				</button> | ||||
| 				<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" | ||||
| 					data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread"> | ||||
| 				<button | ||||
| 					v-else | ||||
| 					class="list-group-item list-group-item-action" | ||||
| 					data-bs-toggle="modal" | ||||
| 					data-bs-target="#MarkAllReadModal" | ||||
| 					:disabled="!mailbox.messages_unread" | ||||
| 				> | ||||
| 					<i class="bi bi-eye-fill me-1"></i> | ||||
| 					Mark all read | ||||
| 				</button> | ||||
| 				<!-- checking if MessageRelay is defined prevents UI flicker while loading --> | ||||
| 				<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton"> | ||||
| 					<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action" | ||||
| 						@click="deleteAllMessages" :disabled="!mailbox.count"> | ||||
| 					<button | ||||
| 						v-if="mailbox.skipConfirmations" | ||||
| 						class="list-group-item list-group-item-action" | ||||
| 						:disabled="!mailbox.count" | ||||
| 						@click="deleteAllMessages" | ||||
| 					> | ||||
| 						<i class="bi bi-trash-fill me-1 text-danger"></i> | ||||
| 						Delete all | ||||
| 					</button> | ||||
| 					<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal" | ||||
| 						data-bs-target="#DeleteAllModal" :disabled="!mailbox.count"> | ||||
| 					<button | ||||
| 						v-else | ||||
| 						class="list-group-item list-group-item-action" | ||||
| 						data-bs-toggle="modal" | ||||
| 						data-bs-target="#DeleteAllModal" | ||||
| 						:disabled="!mailbox.count" | ||||
| 					> | ||||
| 						<i class="bi bi-trash-fill me-1 text-danger"></i> | ||||
| 						Delete all | ||||
| 					</button> | ||||
| 				</template> | ||||
| 			</template> | ||||
|  | ||||
| 			<NavSelected @loadMessages="loadMessages" /> | ||||
| 			<NavSelected @load-messages="loadMessages" /> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template v-else> | ||||
| 		<!-- Modals --> | ||||
| 		<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" | ||||
| 			aria-hidden="true"> | ||||
| 		<div | ||||
| 			id="MarkAllReadModal" | ||||
| 			class="modal fade" | ||||
| 			tabindex="-1" | ||||
| 			aria-labelledby="MarkAllReadModalLabel" | ||||
| 			aria-hidden="true" | ||||
| 		> | ||||
| 			<div class="modal-dialog"> | ||||
| 				<div class="modal-content"> | ||||
| 					<div class="modal-header"> | ||||
| 						<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all search results as read?</h5> | ||||
| 						<h5 id="MarkAllReadModalLabel" class="modal-title">Mark all search results as read?</h5> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						This will mark {{ formatNumber(mailbox.messages_unread) }} | ||||
| 						message<span v-if="mailbox.messages_unread > 1">s</span> | ||||
| 						This will mark {{ formatNumber(mailbox.messages_unread) }} message<span | ||||
| 							v-if="mailbox.messages_unread > 1" | ||||
| 							>s</span | ||||
| 						> | ||||
| 						matching <code>{{ getSearch() }}</code> | ||||
| 						as read. | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | ||||
| 						<button type="button" class="btn btn-success" data-bs-dismiss="modal" | ||||
| 							v-on:click="markAllRead">Confirm</button> | ||||
| 						<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead"> | ||||
| 							Confirm | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" | ||||
| 			aria-hidden="true"> | ||||
| 		<div | ||||
| 			id="DeleteAllModal" | ||||
| 			class="modal fade" | ||||
| 			tabindex="-1" | ||||
| 			aria-labelledby="DeleteAllModalLabel" | ||||
| 			aria-hidden="true" | ||||
| 		> | ||||
| 			<div class="modal-dialog"> | ||||
| 				<div class="modal-content"> | ||||
| 					<div class="modal-header"> | ||||
| 						<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages matching search?</h5> | ||||
| 						<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages matching search?</h5> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						This will permanently delete {{ formatNumber(mailbox.count) }} | ||||
| 						message<span v-if="mailbox.count > 1">s</span> matching | ||||
| 						This will permanently delete {{ formatNumber(mailbox.count) }} message<span | ||||
| 							v-if="mailbox.count > 1" | ||||
| 							>s</span | ||||
| 						> | ||||
| 						matching | ||||
| 						<code>{{ getSearch() }}</code> | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | ||||
| 						<button type="button" class="btn btn-danger" data-bs-dismiss="modal" | ||||
| 							v-on:click="deleteAllMessages">Delete</button> | ||||
| 						<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages"> | ||||
| 							Delete | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
|   | ||||
| @@ -1,118 +1,124 @@ | ||||
| <script> | ||||
| import AjaxLoader from './AjaxLoader.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import AjaxLoader from "./AjaxLoader.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
|  | ||||
| export default { | ||||
|     mixins: [CommonMixins], | ||||
| 	components: { | ||||
| 		AjaxLoader, | ||||
| 	}, | ||||
|  | ||||
|     components: { | ||||
|         AjaxLoader, | ||||
|     }, | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
|     emits: ['loadMessages'], | ||||
| 	emits: ["loadMessages"], | ||||
|  | ||||
|     data() { | ||||
|         return { | ||||
|             mailbox, | ||||
|         } | ||||
|     }, | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
|     methods: { | ||||
|         loadMessages() { | ||||
|             this.$emit('loadMessages') | ||||
|         }, | ||||
| 	methods: { | ||||
| 		loadMessages() { | ||||
| 			this.$emit("loadMessages"); | ||||
| 		}, | ||||
|  | ||||
|         // mark selected messages as read | ||||
|         markSelectedRead() { | ||||
|             if (!mailbox.selected.length) { | ||||
|                 return false | ||||
|             } | ||||
|             this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => { | ||||
|                 window.scrollInPlace = true | ||||
|                 this.loadMessages() | ||||
|             }) | ||||
|         }, | ||||
| 		// mark selected messages as read | ||||
| 		markSelectedRead() { | ||||
| 			if (!mailbox.selected.length) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			this.put(this.resolve(`/api/v1/messages`), { Read: true, IDs: mailbox.selected }, (response) => { | ||||
| 				window.scrollInPlace = true; | ||||
| 				this.loadMessages(); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
|         isSelected(id) { | ||||
|             return mailbox.selected.indexOf(id) != -1 | ||||
|         }, | ||||
| 		isSelected(id) { | ||||
| 			return mailbox.selected.indexOf(id) !== -1; | ||||
| 		}, | ||||
|  | ||||
|         // mark selected messages as unread | ||||
|         markSelectedUnread() { | ||||
|             if (!mailbox.selected.length) { | ||||
|                 return false | ||||
|             } | ||||
|             this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => { | ||||
|                 window.scrollInPlace = true | ||||
|                 this.loadMessages() | ||||
|             }) | ||||
|         }, | ||||
| 		// mark selected messages as unread | ||||
| 		markSelectedUnread() { | ||||
| 			if (!mailbox.selected.length) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			this.put(this.resolve(`/api/v1/messages`), { Read: false, IDs: mailbox.selected }, (response) => { | ||||
| 				window.scrollInPlace = true; | ||||
| 				this.loadMessages(); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
|         // universal handler to delete current or selected messages | ||||
|         deleteMessages() { | ||||
|             let ids = [] | ||||
|             ids = JSON.parse(JSON.stringify(mailbox.selected)) | ||||
|             if (!ids.length) { | ||||
|                 return false | ||||
|             } | ||||
| 		// universal handler to delete current or selected messages | ||||
| 		deleteMessages() { | ||||
| 			let ids = []; | ||||
| 			ids = JSON.parse(JSON.stringify(mailbox.selected)); | ||||
| 			if (!ids.length) { | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
|             this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => { | ||||
|                 window.scrollInPlace = true | ||||
|                 this.loadMessages() | ||||
|             }) | ||||
|         }, | ||||
| 			this.delete(this.resolve(`/api/v1/messages`), { IDs: ids }, (response) => { | ||||
| 				window.scrollInPlace = true; | ||||
| 				this.loadMessages(); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
|         // test if any selected emails are unread | ||||
|         selectedHasUnread() { | ||||
|             if (!mailbox.selected.length) { | ||||
|                 return false | ||||
|             } | ||||
|             for (let i in mailbox.messages) { | ||||
|                 if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) { | ||||
|                     return true | ||||
|                 } | ||||
|             } | ||||
|             return false | ||||
|         }, | ||||
| 		// test if any selected emails are unread | ||||
| 		selectedHasUnread() { | ||||
| 			if (!mailbox.selected.length) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			for (const i in mailbox.messages) { | ||||
| 				if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
| 			return false; | ||||
| 		}, | ||||
|  | ||||
|         // test of any selected emails are read | ||||
|         selectedHasRead() { | ||||
|             if (!mailbox.selected.length) { | ||||
|                 return false | ||||
|             } | ||||
|             for (let i in mailbox.messages) { | ||||
|                 if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) { | ||||
|                     return true | ||||
|                 } | ||||
|             } | ||||
|             return false | ||||
|         }, | ||||
|     } | ||||
| } | ||||
| 		// test of any selected emails are read | ||||
| 		selectedHasRead() { | ||||
| 			if (!mailbox.selected.length) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			for (const i in mailbox.messages) { | ||||
| 				if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
| 			return false; | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <template v-if="mailbox.selected.length"> | ||||
|         <button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()" | ||||
|             v-on:click="markSelectedRead"> | ||||
|             <i class="bi bi-eye-fill me-1"></i> | ||||
|             Mark read | ||||
|         </button> | ||||
|         <button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()" | ||||
|             v-on:click="markSelectedUnread"> | ||||
|             <i class="bi bi-eye-slash me-1"></i> | ||||
|             Mark unread | ||||
|         </button> | ||||
|         <button class="list-group-item list-group-item-action" v-on:click="deleteMessages()"> | ||||
|             <i class="bi bi-trash-fill me-1 text-danger"></i> | ||||
|             Delete selected | ||||
|         </button> | ||||
|         <button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []"> | ||||
|             <i class="bi bi-x-circle me-1"></i> | ||||
|             Cancel selection | ||||
|         </button> | ||||
|     </template> | ||||
| 	<template v-if="mailbox.selected.length"> | ||||
| 		<button | ||||
| 			class="list-group-item list-group-item-action" | ||||
| 			:disabled="!selectedHasUnread()" | ||||
| 			@click="markSelectedRead" | ||||
| 		> | ||||
| 			<i class="bi bi-eye-fill me-1"></i> | ||||
| 			Mark read | ||||
| 		</button> | ||||
| 		<button | ||||
| 			class="list-group-item list-group-item-action" | ||||
| 			:disabled="!selectedHasRead()" | ||||
| 			@click="markSelectedUnread" | ||||
| 		> | ||||
| 			<i class="bi bi-eye-slash me-1"></i> | ||||
| 			Mark unread | ||||
| 		</button> | ||||
| 		<button class="list-group-item list-group-item-action" @click="deleteMessages()"> | ||||
| 			<i class="bi bi-trash-fill me-1 text-danger"></i> | ||||
| 			Delete selected | ||||
| 		</button> | ||||
| 		<button class="list-group-item list-group-item-action" @click="mailbox.selected = []"> | ||||
| 			<i class="bi bi-x-circle me-1"></i> | ||||
| 			Cancel selection | ||||
| 		</button> | ||||
| 	</template> | ||||
|  | ||||
|     <AjaxLoader :loading="loading" /> | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| @@ -10,79 +10,77 @@ export default { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		// test whether a tag is currently being searched for (in the URL) | ||||
| 		inSearch(tag) { | ||||
| 			const urlParams = new URLSearchParams(window.location.search) | ||||
| 			const query = urlParams.get('q') | ||||
| 			const urlParams = new URLSearchParams(window.location.search); | ||||
| 			const query = urlParams.get("q"); | ||||
| 			if (!query) { | ||||
| 				return false | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i') | ||||
| 			return query.match(re) | ||||
| 			const re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, "i"); | ||||
| 			return query.match(re); | ||||
| 		}, | ||||
|  | ||||
| 		// toggle a tag search in the search URL, add or remove it accordingly | ||||
| 		toggleTag(e, tag) { | ||||
| 			e.preventDefault() | ||||
| 			e.preventDefault(); | ||||
|  | ||||
| 			const urlParams = new URLSearchParams(window.location.search) | ||||
| 			let query = urlParams.get('q') ? urlParams.get('q') : '' | ||||
| 			const urlParams = new URLSearchParams(window.location.search); | ||||
| 			let query = urlParams.get("q") ? urlParams.get("q") : ""; | ||||
|  | ||||
| 			let re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, 'i') | ||||
| 			const re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, "i"); | ||||
|  | ||||
| 			if (query.match(re)) { | ||||
| 				// remove is exists | ||||
| 				query = query.replace(re, '$1$4') | ||||
| 				query = query.replace(re, "$1$4"); | ||||
| 			} else { | ||||
| 				// add to query | ||||
| 				if (tag.match(/ /)) { | ||||
| 					tag = `"${tag}"` | ||||
| 					tag = `"${tag}"`; | ||||
| 				} | ||||
| 				query = query + " tag:" + tag | ||||
| 				query = query + " tag:" + tag; | ||||
| 			} | ||||
|  | ||||
| 			query = query.trim() | ||||
| 			query = query.trim(); | ||||
|  | ||||
| 			if (query == '') { | ||||
| 				this.$router.push('/') | ||||
| 			if (query === "") { | ||||
| 				this.$router.push("/"); | ||||
| 			} else { | ||||
| 				const params = new URLSearchParams({ | ||||
| 					q: query, | ||||
| 					start: pagination.start.toString(), | ||||
| 					limit: pagination.limit.toString(), | ||||
| 				}) | ||||
| 				this.$router.push('/search?' + params.toString()) | ||||
| 				}); | ||||
| 				this.$router.push("/search?" + params.toString()); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		toTagUrl(t) { | ||||
| 			if (t.match(/ /)) { | ||||
| 				t = `"${t}"` | ||||
| 				t = `"${t}"`; | ||||
| 			} | ||||
| 			const p = { | ||||
| 				q: 'tag:' + t | ||||
| 				q: "tag:" + t, | ||||
| 			}; | ||||
| 			if (pagination.limit !== pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString(); | ||||
| 			} | ||||
| 			if (pagination.limit != pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString() | ||||
| 			} | ||||
| 			const params = new URLSearchParams(p) | ||||
| 			return '/search?' + params.toString() | ||||
| 			const params = new URLSearchParams(p); | ||||
| 			return "/search?" + params.toString(); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<template v-if="mailbox.tags && mailbox.tags.length"> | ||||
| 		<div class="mt-4 text-muted"> | ||||
| 			<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
| 				Tags | ||||
| 			</button> | ||||
| 			<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Tags</button> | ||||
| 			<ul class="dropdown-menu dropdown-menu-end"> | ||||
| 				<li> | ||||
| 					<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal"> | ||||
| @@ -99,12 +97,20 @@ export default { | ||||
| 			</ul> | ||||
| 		</div> | ||||
| 		<div class="list-group mt-1 mb-2"> | ||||
| 			<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav" | ||||
| 				@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)" | ||||
| 			<RouterLink | ||||
| 				v-for="tag in mailbox.tags" | ||||
| 				:key="tag" | ||||
| 				:to="toTagUrl(tag)" | ||||
| 				:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''" | ||||
| 				class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''"> | ||||
| 				<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i> | ||||
| 				<i class="bi bi-tag" v-else></i> | ||||
| 				class="list-group-item list-group-item-action small px-2" | ||||
| 				:class="inSearch(tag) ? 'active' : ''" | ||||
| 				@click.exact="hideNav" | ||||
| 				@click="pagination.start = 0" | ||||
| 				@click.meta="toggleTag($event, tag)" | ||||
| 				@click.ctrl="toggleTag($event, tag)" | ||||
| 			> | ||||
| 				<i v-if="inSearch(tag)" class="bi bi-tag-fill"></i> | ||||
| 				<i v-else class="bi bi-tag"></i> | ||||
| 				{{ tag }} | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
|   | ||||
| @@ -1,263 +0,0 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { Toast } from 'bootstrap' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			pagination, | ||||
| 			mailbox, | ||||
| 			toastMessage: false, | ||||
| 			reconnectRefresh: false, | ||||
| 			socketURI: false, | ||||
| 			socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections | ||||
| 			socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s | ||||
| 			pauseNotifications: false, // prevent spamming | ||||
| 			version: false, | ||||
| 			clientErrors: [], // errors received via websocket  | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		const d = document.getElementById('app') | ||||
| 		if (d) { | ||||
| 			this.version = d.dataset.version | ||||
| 		} | ||||
|  | ||||
| 		const proto = location.protocol == 'https:' ? 'wss' : 'ws' | ||||
| 		this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`) | ||||
|  | ||||
| 		this.socketBreakReset() | ||||
| 		this.connect() | ||||
|  | ||||
| 		mailbox.notificationsSupported = window.isSecureContext | ||||
| 			&& ("Notification" in window && Notification.permission !== "denied") | ||||
| 		mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted" | ||||
|  | ||||
| 		this.errorNotificationCron() | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		// websocket connect | ||||
| 		connect() { | ||||
| 			const ws = new WebSocket(this.socketURI) | ||||
| 			ws.onmessage = (e) => { | ||||
| 				let response | ||||
| 				try { | ||||
| 					response = JSON.parse(e.data) | ||||
| 				} catch (e) { | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				// new messages | ||||
| 				if (response.Type == "new" && response.Data) { | ||||
| 					this.eventBus.emit("new", response.Data) | ||||
|  | ||||
| 					for (let i in response.Data.Tags) { | ||||
| 						if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) { | ||||
| 							mailbox.tags.push(response.Data.Tags[i]) | ||||
| 							mailbox.tags.sort((a, b) => { | ||||
| 								return a.toLowerCase().localeCompare(b.toLowerCase()) | ||||
| 							}) | ||||
| 						} | ||||
| 					} | ||||
|  | ||||
| 					// send notifications | ||||
| 					if (!this.pauseNotifications) { | ||||
| 						this.pauseNotifications = true | ||||
| 						let from = response.Data.From != null ? response.Data.From.Address : '[unknown]' | ||||
| 						this.browserNotify("New mail from: " + from, response.Data.Subject) | ||||
| 						this.setMessageToast(response.Data) | ||||
| 						// delay notifications by 2s | ||||
| 						window.setTimeout(() => { this.pauseNotifications = false }, 2000) | ||||
| 					} | ||||
| 				} else if (response.Type == "prune") { | ||||
| 					// messages have been deleted, reload messages to adjust | ||||
| 					window.scrollInPlace = true | ||||
| 					mailbox.refresh = true // trigger refresh | ||||
| 					window.setTimeout(() => { mailbox.refresh = false }, 500) | ||||
| 					this.eventBus.emit("prune"); | ||||
| 				} else if (response.Type == "stats" && response.Data) { | ||||
| 					// refresh mailbox stats | ||||
| 					mailbox.total = response.Data.Total | ||||
| 					mailbox.unread = response.Data.Unread | ||||
|  | ||||
| 					// detect version updated, refresh is needed | ||||
| 					if (this.version != response.Data.Version) { | ||||
| 						location.reload() | ||||
| 					} | ||||
| 				} else if (response.Type == "delete" && response.Data) { | ||||
| 					// broadcast for components | ||||
| 					this.eventBus.emit("delete", response.Data) | ||||
| 				} else if (response.Type == "update" && response.Data) { | ||||
| 					// broadcast for components | ||||
| 					this.eventBus.emit("update", response.Data) | ||||
| 				} else if (response.Type == "truncate") { | ||||
| 					// broadcast for components | ||||
| 					this.eventBus.emit("truncate") | ||||
| 				} else if (response.Type == "error") { | ||||
| 					// broadcast for components | ||||
| 					this.addClientError(response.Data) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			ws.onopen = () => { | ||||
| 				mailbox.connected = true | ||||
| 				this.socketLastConnection = Date.now() | ||||
| 				if (this.reconnectRefresh) { | ||||
| 					this.reconnectRefresh = false | ||||
| 					mailbox.refresh = true // trigger refresh | ||||
| 					window.setTimeout(() => { mailbox.refresh = false }, 500) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			ws.onclose = (e) => { | ||||
| 				if (this.socketLastConnection == 0) { | ||||
| 					// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured | ||||
| 					console.log('Unable to connect to websocket, disabling websocket support') | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				if (mailbox.connected) { | ||||
| 					// count disconnections | ||||
| 					this.socketBreaks++ | ||||
| 				} | ||||
|  | ||||
| 				// set disconnected state | ||||
| 				mailbox.connected = false | ||||
|  | ||||
| 				if (this.socketBreaks > 3) { | ||||
| 					// give up after > 3 successful socket connections & disconnections within a 15 second window, | ||||
| 					// something is not working right on their end, see issue #319 | ||||
| 					console.log('Unstable websocket connection, disabling websocket support') | ||||
| 					return | ||||
| 				} | ||||
| 				if (Date.now() - this.socketLastConnection > 5000) { | ||||
| 					// only refresh mailbox if the last successful connection was broken for > 5 seconds | ||||
| 					this.reconnectRefresh = true | ||||
| 				} else { | ||||
| 					this.reconnectRefresh = false | ||||
| 				} | ||||
|  | ||||
| 				setTimeout(() => { | ||||
| 					this.connect() // reconnect | ||||
| 				}, 1000) | ||||
| 			} | ||||
|  | ||||
| 			ws.onerror = function (err) { | ||||
| 				ws.close() | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		socketBreakReset() { | ||||
| 			window.setTimeout(() => { | ||||
| 				this.socketBreaks = 0 | ||||
| 				this.socketBreakReset() | ||||
| 			}, 15000) | ||||
| 		}, | ||||
|  | ||||
| 		browserNotify(title, message) { | ||||
| 			if (!("Notification" in window)) { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			if (Notification.permission === "granted") { | ||||
| 				let options = { | ||||
| 					body: message, | ||||
| 					icon: this.resolve('/notification.png') | ||||
| 				} | ||||
| 				new Notification(title, options) | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		setMessageToast(m) { | ||||
| 			// don't display if browser notifications are enabled, or a toast is already displayed | ||||
| 			if (mailbox.notificationsEnabled || this.toastMessage) { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			this.toastMessage = m | ||||
|  | ||||
| 			const el = document.getElementById('messageToast') | ||||
| 			if (el) { | ||||
| 				el.addEventListener('hidden.bs.toast', () => { | ||||
| 					this.toastMessage = false | ||||
| 				}) | ||||
|  | ||||
| 				Toast.getOrCreateInstance(el).show() | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		closeToast() { | ||||
| 			const el = document.getElementById('messageToast') | ||||
| 			if (el) { | ||||
| 				Toast.getOrCreateInstance(el).hide() | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		addClientError(d) { | ||||
| 			d.expire = Date.now() + 5000 // expire after 5s | ||||
| 			this.clientErrors.push(d) | ||||
| 		}, | ||||
|  | ||||
| 		errorNotificationCron() { | ||||
| 			window.setTimeout(() => { | ||||
| 				this.clientErrors.forEach((err, idx) => { | ||||
| 					if (err.expire < Date.now()) { | ||||
| 						this.clientErrors.splice(idx, 1) | ||||
| 					} | ||||
| 				}) | ||||
| 				this.errorNotificationCron() | ||||
| 			}, 1000) | ||||
| 		} | ||||
| 	}, | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="toast-container position-fixed bottom-0 end-0 p-3"> | ||||
| 		<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true"> | ||||
| 			<div class="toast-header"> | ||||
| 				<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg" | ||||
| 					aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false"> | ||||
| 					<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect> | ||||
| 				</svg> | ||||
| 				<strong class="me-auto">{{ error.Type }}</strong> | ||||
| 				<small class="text-body-secondary">{{ error.IP }}</small> | ||||
| 				<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> | ||||
| 			</div> | ||||
| 			<div class="toast-body"> | ||||
| 				{{ error.Message }} | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true"> | ||||
| 			<div class="toast-header" v-if="toastMessage"> | ||||
| 				<i class="bi bi-envelope-exclamation-fill me-2"></i> | ||||
| 				<strong class="me-auto"> | ||||
| 					<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink> | ||||
| 				</strong> | ||||
| 				<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="toast-body"> | ||||
| 				<div> | ||||
| 					<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary" | ||||
| 						@click="closeToast"> | ||||
| 						<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template> | ||||
| 						<template v-else> | ||||
| 							[ no subject ] | ||||
| 						</template> | ||||
| 					</RouterLink> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
| @@ -1,107 +0,0 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { limitOptions, pagination } from '../stores/pagination' | ||||
|  | ||||
| export default { | ||||
|  | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		total: Number, | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			pagination, | ||||
| 			mailbox, | ||||
| 			limitOptions, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		canPrev() { | ||||
| 			return pagination.start > 0 | ||||
| 		}, | ||||
|  | ||||
| 		canNext() { | ||||
| 			return this.total > (pagination.start + mailbox.messages.length) | ||||
| 		}, | ||||
|  | ||||
| 		// returns the number of next X messages | ||||
| 		nextMessages() { | ||||
| 			let t = pagination.start + parseInt(pagination.limit, 10) | ||||
| 			if (t > this.total) { | ||||
| 				t = this.total | ||||
| 			} | ||||
|  | ||||
| 			return t | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		changeLimit() { | ||||
| 			pagination.start = 0 | ||||
| 			this.updateQueryParams() | ||||
| 		}, | ||||
|  | ||||
| 		viewNext() { | ||||
| 			pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10) | ||||
| 			this.updateQueryParams() | ||||
| 		}, | ||||
|  | ||||
| 		viewPrev() { | ||||
| 			let s = pagination.start - pagination.limit | ||||
| 			if (s < 0) { | ||||
| 				s = 0 | ||||
| 			} | ||||
| 			pagination.start = s | ||||
| 			this.updateQueryParams() | ||||
| 		}, | ||||
|  | ||||
| 		updateQueryParams() { | ||||
| 			const path = this.$route.path | ||||
| 			const p = { | ||||
| 				...this.$route.query | ||||
| 			} | ||||
| 			if (pagination.start > 0) { | ||||
| 				p.start = pagination.start.toString() | ||||
| 			} else { | ||||
| 				delete p.start | ||||
| 			} | ||||
| 			if (pagination.limit != pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString() | ||||
| 			} else { | ||||
| 				delete p.limit | ||||
| 			} | ||||
| 			const params = new URLSearchParams(p) | ||||
| 			this.$router.push(path + '?' + params.toString()) | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| </script> | ||||
| <template> | ||||
| 	<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2" | ||||
| 		:disabled="total == 0"> | ||||
| 		<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option> | ||||
| 	</select> | ||||
|  | ||||
| 	<small> | ||||
| 		<template v-if="total > 0"> | ||||
| 			{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }} | ||||
| 			<small>of</small> | ||||
| 			{{ formatNumber(total) }} | ||||
| 		</template> | ||||
| 		<span v-else class="text-muted">0 of 0</span> | ||||
| 	</small> | ||||
|  | ||||
| 	<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev" | ||||
| 		:title="'View previous ' + pagination.limit + ' messages'"> | ||||
| 		<i class="bi bi-caret-left-fill"></i> | ||||
| 	</button> | ||||
| 	<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext" | ||||
| 		:title="'View next ' + pagination.limit + ' messages'"> | ||||
| 		<i class="bi bi-caret-right-fill"></i> | ||||
| 	</button> | ||||
| </template> | ||||
| @@ -1,78 +1,84 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { pagination } from '../stores/pagination' | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
| 	emits: ["loadMessages"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			search: '' | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.searchFromURL() | ||||
| 			search: "", | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		$route() { | ||||
| 			this.searchFromURL() | ||||
| 		} | ||||
| 			this.searchFromURL(); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.searchFromURL(); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		searchFromURL() { | ||||
| 			const urlParams = new URLSearchParams(window.location.search) | ||||
| 			this.search = urlParams.get('q') ? urlParams.get('q') : '' | ||||
| 			const urlParams = new URLSearchParams(window.location.search); | ||||
| 			this.search = urlParams.get("q") ? urlParams.get("q") : ""; | ||||
| 		}, | ||||
|  | ||||
| 		doSearch(e) { | ||||
| 			pagination.start = 0 | ||||
| 			if (this.search == '') { | ||||
| 				this.$router.push('/') | ||||
| 			pagination.start = 0; | ||||
| 			if (this.search === "") { | ||||
| 				this.$router.push("/"); | ||||
| 			} else { | ||||
| 				const urlParams = new URLSearchParams(window.location.search) | ||||
| 				const curr = urlParams.get('q') | ||||
| 				if (curr && curr == this.search) { | ||||
| 					pagination.start = 0 | ||||
| 					this.$emit('loadMessages') | ||||
| 				const urlParams = new URLSearchParams(window.location.search); | ||||
| 				const curr = urlParams.get("q"); | ||||
| 				if (curr && curr === this.search) { | ||||
| 					pagination.start = 0; | ||||
| 					this.$emit("loadMessages"); | ||||
| 				} | ||||
| 				const p = { | ||||
| 					q: this.search | ||||
| 				} | ||||
| 					q: this.search, | ||||
| 				}; | ||||
| 				if (pagination.start > 0) { | ||||
| 					p.start = pagination.start.toString() | ||||
| 					p.start = pagination.start.toString(); | ||||
| 				} | ||||
| 				if (pagination.limit != pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString() | ||||
| 				if (pagination.limit !== pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString(); | ||||
| 				} | ||||
|  | ||||
| 				const params = new URLSearchParams(p) | ||||
| 				this.$router.push('/search?' + params.toString()) | ||||
| 				const params = new URLSearchParams(p); | ||||
| 				this.$router.push("/search?" + params.toString()); | ||||
| 			} | ||||
|  | ||||
| 			e.preventDefault() | ||||
| 			e.preventDefault(); | ||||
| 		}, | ||||
|  | ||||
| 		resetSearch() { | ||||
| 			this.search = '' | ||||
| 			this.$router.push('/') | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 			this.search = ""; | ||||
| 			this.$router.push("/"); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<form v-on:submit="doSearch"> | ||||
| 	<form @submit="doSearch"> | ||||
| 		<div class="input-group flex-nowrap"> | ||||
| 			<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative"> | ||||
| 				<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search" | ||||
| 					placeholder="Search mailbox"> | ||||
| 				<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''" | ||||
| 					v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span> | ||||
| 				<input | ||||
| 					v-model.trim="search" | ||||
| 					type="text" | ||||
| 					class="form-control border-0" | ||||
| 					aria-label="Search" | ||||
| 					placeholder="Search mailbox" | ||||
| 				/> | ||||
| 				<span v-if="search != ''" class="btn btn-link position-absolute end-0 text-muted" @click="resetSearch" | ||||
| 					><i class="bi bi-x-circle"></i | ||||
| 				></span> | ||||
| 			</div> | ||||
| 			<button class="btn btn-outline-secondary" type="submit"> | ||||
| 				<i class="bi bi-search"></i> | ||||
|   | ||||
| @@ -1,295 +0,0 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import Tags from 'bootstrap5-tags' | ||||
| import timezones from 'timezones-list' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto', | ||||
| 			timezones, | ||||
| 			chaosConfig: false, | ||||
| 			chaosUpdated: false, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		theme(v) { | ||||
| 			if (v == 'auto') { | ||||
| 				localStorage.removeItem('theme') | ||||
| 			} else { | ||||
| 				localStorage.setItem('theme', v) | ||||
| 			} | ||||
| 			this.setTheme() | ||||
| 		}, | ||||
|  | ||||
| 		chaosConfig: { | ||||
| 			handler() { | ||||
| 				this.chaosUpdated = true | ||||
| 			}, | ||||
| 			deep: true | ||||
| 		}, | ||||
|  | ||||
| 		'mailbox.skipConfirmations'(v) { | ||||
| 			if (v) { | ||||
| 				localStorage.setItem('skip-confirmations', 'true') | ||||
| 			} else { | ||||
| 				localStorage.removeItem('skip-confirmations') | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.setTheme() | ||||
| 		this.$nextTick(function () { | ||||
| 			Tags.init('select.tz') | ||||
| 		}) | ||||
|  | ||||
| 		mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		setTheme() { | ||||
| 			if ( | ||||
| 				this.theme === 'auto' && | ||||
| 				window.matchMedia('(prefers-color-scheme: dark)').matches | ||||
| 			) { | ||||
| 				document.documentElement.setAttribute('data-bs-theme', 'dark') | ||||
| 			} else { | ||||
| 				document.documentElement.setAttribute('data-bs-theme', this.theme) | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		loadChaos() { | ||||
| 			this.get(this.resolve('/api/v1/chaos'), null, (response) => { | ||||
| 				this.chaosConfig = response.data | ||||
| 				this.$nextTick(() => { | ||||
| 					this.chaosUpdated = false | ||||
| 				}) | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		saveChaos() { | ||||
| 			this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => { | ||||
| 				this.chaosConfig = response.data | ||||
| 				this.$nextTick(() => { | ||||
| 					this.chaosUpdated = false | ||||
| 				}) | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true" | ||||
| 		data-bs-keyboard="false"> | ||||
| 		<div class="modal-dialog modal-lg"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h5 class="modal-title" id="SettingsModalLabel">Mailpit settings</h5> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled"> | ||||
| 						<li class="nav-item" role="presentation"> | ||||
| 							<button class="nav-link active" id="ui-tab" data-bs-toggle="tab" | ||||
| 								data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane" | ||||
| 								aria-selected="true">Web UI</button> | ||||
| 						</li> | ||||
| 						<li class="nav-item" role="presentation"> | ||||
| 							<button class="nav-link" id="chaos-tab" data-bs-toggle="tab" | ||||
| 								data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane" | ||||
| 								aria-selected="false" @click="loadChaos">Chaos</button> | ||||
| 						</li> | ||||
| 					</ul> | ||||
|  | ||||
| 					<div class="tab-content"> | ||||
| 						<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab" | ||||
| 							tabindex="0"> | ||||
| 							<div class="my-3"> | ||||
| 								<label for="theme" class="form-label">Mailpit theme</label> | ||||
| 								<select class="form-select" v-model="theme" id="theme"> | ||||
| 									<option value="auto">Auto (detect from browser)</option> | ||||
| 									<option value="light">Light theme</option> | ||||
| 									<option value="dark">Dark theme</option> | ||||
| 								</select> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<label for="timezone" class="form-label">Timezone (for date searches)</label> | ||||
| 								<select class="form-select tz" v-model="mailbox.timeZone" id="timezone" | ||||
| 									data-allow-same="true"> | ||||
| 									<option disabled hidden value="">Select a timezone...</option> | ||||
| 									<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option> | ||||
| 								</select> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input class="form-check-input" type="checkbox" role="switch" id="tagColors" | ||||
| 										v-model="mailbox.showTagColors"> | ||||
| 									<label class="form-check-label" for="tagColors"> | ||||
| 										Use auto-generated tag colors | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck" | ||||
| 										v-model="mailbox.showHTMLCheck"> | ||||
| 									<label class="form-check-label" for="htmlCheck"> | ||||
| 										Show HTML check message tab | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input class="form-check-input" type="checkbox" role="switch" id="linkCheck" | ||||
| 										v-model="mailbox.showLinkCheck"> | ||||
| 									<label class="form-check-label" for="linkCheck"> | ||||
| 										Show link check message tab | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input class="form-check-input" type="checkbox" role="switch" id="spamCheck" | ||||
| 										v-model="mailbox.showSpamCheck"> | ||||
| 									<label class="form-check-label" for="spamCheck"> | ||||
| 										Show spam check message tab | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="mb-3"> | ||||
| 								<div class="form-check form-switch"> | ||||
| 									<input class="form-check-input" type="checkbox" role="switch" | ||||
| 										id="skip-confirmations" v-model="mailbox.skipConfirmations"> | ||||
| 									<label class="form-check-label" for="skip-confirmations"> | ||||
| 										Skip | ||||
| 										<template v-if="!mailbox.uiConfig.HideDeleteAllButton"> | ||||
| 											<code>Delete all</code> & | ||||
| 										</template> | ||||
| 										<code>Mark all read</code> confirmation dialogs | ||||
| 									</label> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
|  | ||||
| 						<div class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab" | ||||
| 							tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled"> | ||||
| 							<p class="my-3"> | ||||
| 								<b>Chaos</b> allows you to set random SMTP failures and response codes at various | ||||
| 								stages in a SMTP transaction to test application resilience | ||||
| 								(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank"> | ||||
| 									see documentation | ||||
| 								</a>). | ||||
| 							</p> | ||||
|  | ||||
| 							<ul> | ||||
| 								<li> | ||||
| 									<code>Response code</code> is the SMTP error code returned by the server if this | ||||
| 									error is triggered. Error codes must range between 400 and 599. | ||||
| 								</li> | ||||
| 								<li> | ||||
| 									<code>Error probability</code> is the % chance that the error will occur per message | ||||
| 									delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always | ||||
| 									trigger. A probability of <code>50</code> will trigger on approximately 50% of | ||||
| 									messages received. | ||||
| 								</li> | ||||
| 							</ul> | ||||
|  | ||||
| 							<template v-if="chaosConfig"> | ||||
| 								<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''"> | ||||
| 									<div class="mb-4"> | ||||
| 										<label>Trigger: <code>Sender</code></label> | ||||
| 										<div class="form-text"> | ||||
| 											Trigger an error response based on the sender (From / Sender). | ||||
| 										</div> | ||||
| 										<div class="row mt-1"> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Response code | ||||
| 												</label> | ||||
| 												<input type="number" class="form-control" | ||||
| 													v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599" | ||||
| 													required> | ||||
| 											</div> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Error probability ({{ chaosConfig.Sender.Probability }}%) | ||||
| 												</label> | ||||
| 												<input type="range" class="form-range mt-1" min="0" max="100" | ||||
| 													v-model.number="chaosConfig.Sender.Probability"> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
|  | ||||
| 									<div class="mb-4"> | ||||
| 										<label>Trigger: <code>Recipient</code></label> | ||||
| 										<div class="form-text"> | ||||
| 											Trigger an error response based on the recipients (To, Cc, Bcc). | ||||
| 										</div> | ||||
| 										<div class="row mt-1"> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Response code | ||||
| 												</label> | ||||
| 												<input type="number" class="form-control" | ||||
| 													v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599" | ||||
| 													required> | ||||
| 											</div> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Error probability ({{ chaosConfig.Recipient.Probability }}%) | ||||
| 												</label> | ||||
| 												<input type="range" class="form-range mt-1" min="0" max="100" | ||||
| 													v-model.number="chaosConfig.Recipient.Probability"> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
|  | ||||
| 									<div class="mb-4"> | ||||
| 										<label>Trigger: <code>Authentication</code></label> | ||||
| 										<div class="form-text"> | ||||
| 											Trigger an authentication error response. | ||||
| 											Note that SMTP authentication must be configured too. | ||||
| 										</div> | ||||
| 										<div class="row mt-1"> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Response code | ||||
| 												</label> | ||||
| 												<input type="number" class="form-control" | ||||
| 													v-model.number="chaosConfig.Authentication.ErrorCode" min="400" | ||||
| 													max="599" required> | ||||
| 											</div> | ||||
| 											<div class="col"> | ||||
| 												<label class="form-label"> | ||||
| 													Error probability ({{ chaosConfig.Authentication.Probability }}%) | ||||
| 												</label> | ||||
| 												<input type="range" class="form-range mt-1" min="0" max="100" | ||||
| 													v-model.number="chaosConfig.Authentication.Probability"> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
|  | ||||
| 								<div v-if="chaosUpdated" class="mb-3 text-center"> | ||||
| 									<button class="btn btn-success" @click="saveChaos">Update Chaos</button> | ||||
| 								</div> | ||||
| 							</template> | ||||
| 						</div> | ||||
|  | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
| @@ -1,221 +1,234 @@ | ||||
| <script> | ||||
| import { VcDonut } from 'vue-css-donut-chart' | ||||
| import axios from 'axios' | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import { Tooltip } from 'bootstrap' | ||||
| import { VcDonut } from "vue-css-donut-chart"; | ||||
| import axios from "axios"; | ||||
| import commonMixins from "../../mixins/CommonMixins"; | ||||
| import { Tooltip } from "bootstrap"; | ||||
| import DOMPurify from "dompurify"; | ||||
|  | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 	}, | ||||
|  | ||||
| 	components: { | ||||
| 		VcDonut, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["setHtmlScore", "setBadgeStyle"], | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["setHtmlScore", "setBadgeStyle"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			error: false, | ||||
| 			check: false, | ||||
| 			platforms: [], | ||||
| 			allPlatforms: { | ||||
| 				"windows": "Windows", | ||||
| 				windows: "Windows", | ||||
| 				"windows-mail": "Windows Mail", | ||||
| 				"outlook-com": "Outlook.com", | ||||
| 				"macos": "macOS", | ||||
| 				"ios": "iOS", | ||||
| 				"android": "Android", | ||||
| 				macos: "macOS", | ||||
| 				ios: "iOS", | ||||
| 				android: "Android", | ||||
| 				"desktop-webmail": "Desktop Webmail", | ||||
| 				"mobile-webmail": "Mobile Webmail", | ||||
| 			}, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.loadConfig() | ||||
| 		this.doCheck() | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		summary() { | ||||
| 			if (!this.check) { | ||||
| 				return false | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			let result = { | ||||
| 			const result = { | ||||
| 				Warnings: [], | ||||
| 				Total: { | ||||
| 					Nodes: this.check.Total.Nodes | ||||
| 				} | ||||
| 			} | ||||
| 					Nodes: this.check.Total.Nodes, | ||||
| 				}, | ||||
| 			}; | ||||
|  | ||||
| 			for (let i = 0; i < this.check.Warnings.length; i++) { | ||||
| 				let o = JSON.parse(JSON.stringify(this.check.Warnings[i])) | ||||
| 				const o = JSON.parse(JSON.stringify(this.check.Warnings[i])); | ||||
|  | ||||
| 				// for <script> test | ||||
| 				if (o.Results.length == 0) { | ||||
| 					result.Warnings.push(o) | ||||
| 					continue | ||||
| 				if (o.Results.length === 0) { | ||||
| 					result.Warnings.push(o); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// filter by enabled platforms | ||||
| 				let results = o.Results.filter((w) => { | ||||
| 					return this.platforms.indexOf(w.Platform) != -1 | ||||
| 				}) | ||||
| 				const results = o.Results.filter((w) => { | ||||
| 					return this.platforms.indexOf(w.Platform) !== -1; | ||||
| 				}); | ||||
|  | ||||
| 				if (results.length == 0) { | ||||
| 					continue | ||||
| 				if (results.length === 0) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// recalculate the percentages | ||||
| 				let y = 0, p = 0, n = 0 | ||||
| 				let y = 0; | ||||
| 				let p = 0; | ||||
| 				let n = 0; | ||||
|  | ||||
| 				results.forEach(function (r) { | ||||
| 					if (r.Support == "yes") { | ||||
| 						y++ | ||||
| 					} else if (r.Support == "partial") { | ||||
| 						p++ | ||||
| 				results.forEach((r) => { | ||||
| 					if (r.Support === "yes") { | ||||
| 						y++; | ||||
| 					} else if (r.Support === "partial") { | ||||
| 						p++; | ||||
| 					} else { | ||||
| 						n++ | ||||
| 						n++; | ||||
| 					} | ||||
| 				}) | ||||
| 				let total = y + p + n | ||||
| 				o.Results = results | ||||
| 				}); | ||||
| 				const total = y + p + n; | ||||
| 				o.Results = results; | ||||
| 				o.Score = { | ||||
| 					Found: o.Score.Found, | ||||
| 					Supported: y / total * 100, | ||||
| 					Partial: p / total * 100, | ||||
| 					Unsupported: n / total * 100 | ||||
| 				} | ||||
| 					Supported: (y / total) * 100, | ||||
| 					Partial: (p / total) * 100, | ||||
| 					Unsupported: (n / total) * 100, | ||||
| 				}; | ||||
|  | ||||
| 				result.Warnings.push(o) | ||||
| 				result.Warnings.push(o); | ||||
| 			} | ||||
|  | ||||
| 			let maxPartial = 0, maxUnsupported = 0 | ||||
| 			let maxPartial = 0; | ||||
| 			let maxUnsupported = 0; | ||||
| 			result.Warnings.forEach((w) => { | ||||
| 				let scoreWeight = 1 | ||||
| 				let scoreWeight = 1; | ||||
| 				if (w.Score.Found < result.Total.Nodes) { | ||||
| 					// each error is weighted based on the number of occurrences vs: the total message nodes | ||||
| 					scoreWeight = w.Score.Found / result.Total.Nodes | ||||
| 					scoreWeight = w.Score.Found / result.Total.Nodes; | ||||
| 				} | ||||
|  | ||||
| 				// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they | ||||
| 				// are actually used in the HTML, and including things like bootstrap styles completely throws | ||||
| 				// off the calculation as these dominate. | ||||
| 				if (this.isPseudoClassOrAtRule(w.Title)) { | ||||
| 					scoreWeight = 0.05 | ||||
| 					w.PseudoClassOrAtRule = true | ||||
| 					scoreWeight = 0.05; | ||||
| 					w.PseudoClassOrAtRule = true; | ||||
| 				} | ||||
|  | ||||
| 				let scorePartial = w.Score.Partial * scoreWeight | ||||
| 				let scoreUnsupported = w.Score.Unsupported * scoreWeight | ||||
| 				const scorePartial = w.Score.Partial * scoreWeight; | ||||
| 				const scoreUnsupported = w.Score.Unsupported * scoreWeight; | ||||
| 				if (scorePartial > maxPartial) { | ||||
| 					maxPartial = scorePartial | ||||
| 					maxPartial = scorePartial; | ||||
| 				} | ||||
| 				if (scoreUnsupported > maxUnsupported) { | ||||
| 					maxUnsupported = scoreUnsupported | ||||
| 					maxUnsupported = scoreUnsupported; | ||||
| 				} | ||||
| 			}) | ||||
| 			}); | ||||
|  | ||||
| 			// sort warnings by final score | ||||
| 			result.Warnings.sort((a, b) => { | ||||
| 				let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes | ||||
| 				let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes | ||||
| 				let aWeight = | ||||
| 					a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes; | ||||
| 				let bWeight = | ||||
| 					b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes; | ||||
|  | ||||
| 				if (this.isPseudoClassOrAtRule(a.Title)) { | ||||
| 					aWeight = 0.05 | ||||
| 					aWeight = 0.05; | ||||
| 				} | ||||
|  | ||||
| 				if (this.isPseudoClassOrAtRule(b.Title)) { | ||||
| 					bWeight = 0.05 | ||||
| 					bWeight = 0.05; | ||||
| 				} | ||||
|  | ||||
| 				return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight | ||||
| 			}) | ||||
| 				return ( | ||||
| 					(a.Score.Unsupported + a.Score.Partial) * aWeight < | ||||
| 					(b.Score.Unsupported + b.Score.Partial) * bWeight | ||||
| 				); | ||||
| 			}); | ||||
|  | ||||
| 			result.Total.Supported = 100 - maxPartial - maxUnsupported | ||||
| 			result.Total.Partial = maxPartial | ||||
| 			result.Total.Unsupported = maxUnsupported | ||||
| 			result.Total.Supported = 100 - maxPartial - maxUnsupported; | ||||
| 			result.Total.Partial = maxPartial; | ||||
| 			result.Total.Unsupported = maxUnsupported; | ||||
|  | ||||
| 			this.$emit('setHtmlScore', result.Total.Supported) | ||||
| 			this.$emit("setHtmlScore", result.Total.Supported); | ||||
|  | ||||
| 			return result | ||||
| 			return result; | ||||
| 		}, | ||||
|  | ||||
| 		graphSections() { | ||||
| 			let s = Math.round(this.summary.Total.Supported) | ||||
| 			let p = Math.round(this.summary.Total.Partial) | ||||
| 			let u = 100 - s - p | ||||
| 			const s = Math.round(this.summary.Total.Supported); | ||||
| 			const p = Math.round(this.summary.Total.Partial); | ||||
| 			const u = 100 - s - p; | ||||
| 			return [ | ||||
| 				{ | ||||
| 					label: this.round2dm(this.summary.Total.Supported) + '% supported', | ||||
| 					label: this.round2dm(this.summary.Total.Supported) + "% supported", | ||||
| 					value: s, | ||||
| 					color: '#198754' | ||||
| 					color: "#198754", | ||||
| 				}, | ||||
| 				{ | ||||
| 					label: this.round2dm(this.summary.Total.Partial) + '% partially supported', | ||||
| 					label: this.round2dm(this.summary.Total.Partial) + "% partially supported", | ||||
| 					value: p, | ||||
| 					color: '#ffc107' | ||||
| 					color: "#ffc107", | ||||
| 				}, | ||||
| 				{ | ||||
| 					label: this.round2dm(this.summary.Total.Unsupported) + '% not supported', | ||||
| 					label: this.round2dm(this.summary.Total.Unsupported) + "% not supported", | ||||
| 					value: u, | ||||
| 					color: '#dc3545' | ||||
| 				} | ||||
| 			] | ||||
| 					color: "#dc3545", | ||||
| 				}, | ||||
| 			]; | ||||
| 		}, | ||||
|  | ||||
| 		// colors depend on both varying unsupported & partially unsupported percentages | ||||
| 		scoreColor() { | ||||
| 			if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) { | ||||
| 				this.$emit('setBadgeStyle', 'bg-success') | ||||
| 				return 'text-success' | ||||
| 				this.$emit("setBadgeStyle", "bg-success"); | ||||
| 				return "text-success"; | ||||
| 			} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) { | ||||
| 				this.$emit('setBadgeStyle', 'bg-warning text-primary') | ||||
| 				return 'text-warning' | ||||
| 				this.$emit("setBadgeStyle", "bg-warning text-primary"); | ||||
| 				return "text-warning"; | ||||
| 			} | ||||
|  | ||||
| 			this.$emit('setBadgeStyle', 'bg-danger') | ||||
| 			return 'text-danger' | ||||
| 		} | ||||
| 			this.$emit("setBadgeStyle", "bg-danger"); | ||||
| 			return "text-danger"; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		message: { | ||||
| 			handler() { | ||||
| 				this.$emit('setHtmlScore', false) | ||||
| 				this.doCheck() | ||||
| 				this.$emit("setHtmlScore", false); | ||||
| 				this.doCheck(); | ||||
| 			}, | ||||
| 			deep: true | ||||
| 			deep: true, | ||||
| 		}, | ||||
| 		platforms(v) { | ||||
| 			localStorage.setItem('html-check-platforms', JSON.stringify(v)) | ||||
| 			localStorage.setItem("html-check-platforms", JSON.stringify(v)); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.loadConfig(); | ||||
| 		this.doCheck(); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		doCheck() { | ||||
| 			this.check = false | ||||
| 			this.check = false; | ||||
|  | ||||
| 			if (this.message.HTML == "") { | ||||
| 				return | ||||
| 			if (this.message.HTML === "") { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// ignore any error, do not show loader | ||||
| 			axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null) | ||||
| 			axios | ||||
| 				.get(this.resolve("/api/v1/message/" + this.message.ID + "/html-check"), null) | ||||
| 				.then((result) => { | ||||
| 					this.check = result.data | ||||
| 					this.error = false | ||||
| 					this.check = result.data; | ||||
| 					this.error = false; | ||||
|  | ||||
| 					// set tooltips | ||||
| 					window.setTimeout(() => { | ||||
| 						const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); | ||||
| 						[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) | ||||
| 					}, 500) | ||||
| 						[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl)); | ||||
| 					}, 500); | ||||
| 				}) | ||||
| 				.catch((error) => { | ||||
| 					// handle error | ||||
| @@ -223,68 +236,72 @@ export default { | ||||
| 						// The request was made and the server responded with a status code | ||||
| 						// that falls out of the range of 2xx | ||||
| 						if (error.response.data.Error) { | ||||
| 							this.error = error.response.data.Error | ||||
| 							this.error = error.response.data.Error; | ||||
| 						} else { | ||||
| 							this.error = error.response.data | ||||
| 							this.error = error.response.data; | ||||
| 						} | ||||
| 					} else if (error.request) { | ||||
| 						// The request was made but no response was received | ||||
| 						// `error.request` is an instance of XMLHttpRequest in the browser and an instance of | ||||
| 						// http.ClientRequest in node.js | ||||
| 						this.error = 'Error sending data to the server. Please try again.' | ||||
| 						this.error = "Error sending data to the server. Please try again."; | ||||
| 					} else { | ||||
| 						// Something happened in setting up the request that triggered an Error | ||||
| 						this.error = error.message | ||||
| 						this.error = error.message; | ||||
| 					} | ||||
| 				}) | ||||
| 				}); | ||||
| 		}, | ||||
|  | ||||
| 		loadConfig() { | ||||
| 			let platforms = localStorage.getItem('html-check-platforms') | ||||
| 			const platforms = localStorage.getItem("html-check-platforms"); | ||||
| 			if (platforms) { | ||||
| 				try { | ||||
| 					this.platforms = JSON.parse(platforms) | ||||
| 				} catch (e) { | ||||
| 				} | ||||
| 					this.platforms = JSON.parse(platforms); | ||||
| 				} catch (e) {} | ||||
| 			} | ||||
|  | ||||
| 			// set all options | ||||
| 			if (this.platforms.length == 0) { | ||||
| 				this.platforms = Object.keys(this.allPlatforms) | ||||
| 			if (this.platforms.length === 0) { | ||||
| 				this.platforms = Object.keys(this.allPlatforms); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		// return a platform's families (email clients) | ||||
| 		families(k) { | ||||
| 			if (this.check.Platforms[k]) { | ||||
| 				return this.check.Platforms[k] | ||||
| 				return this.check.Platforms[k]; | ||||
| 			} | ||||
|  | ||||
| 			return [] | ||||
| 			return []; | ||||
| 		}, | ||||
|  | ||||
| 		// return whether the test string is a pseudo class (:<test>) or at rule (@<test>) | ||||
| 		isPseudoClassOrAtRule(t) { | ||||
| 			return t.match(/^(:|@)/) | ||||
| 			return t.match(/^(:|@)/); | ||||
| 		}, | ||||
|  | ||||
| 		round(v) { | ||||
| 			return Math.round(v) | ||||
| 			return Math.round(v); | ||||
| 		}, | ||||
|  | ||||
| 		round2dm(v) { | ||||
| 			return Math.round(v * 100) / 100 | ||||
| 			return Math.round(v * 100) / 100; | ||||
| 		}, | ||||
|  | ||||
| 		scrollToWarnings() { | ||||
| 			if (!this.$refs.warnings) { | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			this.$refs.warnings.scrollIntoView({ behavior: "smooth" }) | ||||
| 			this.$refs.warnings.scrollIntoView({ behavior: "smooth" }); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 		// Sanitize HTML to prevent XSS | ||||
| 		sanitizeHTML(html) { | ||||
| 			return DOMPurify.sanitize(html); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -299,39 +316,50 @@ export default { | ||||
| 		<div class="mt-5 mb-3"> | ||||
| 			<div class="row w-100"> | ||||
| 				<div class="col-md-8"> | ||||
| 					<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" | ||||
| 						:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0" | ||||
| 						:auto-adjust-text-size="true" @section-click="scrollToWarnings"> | ||||
| 					<vc-donut | ||||
| 						:sections="graphSections" | ||||
| 						background="var(--bs-body-bg)" | ||||
| 						:size="180" | ||||
| 						unit="px" | ||||
| 						:thickness="20" | ||||
| 						has-legend | ||||
| 						legend-placement="bottom" | ||||
| 						:total="100" | ||||
| 						:start-angle="0" | ||||
| 						:auto-adjust-text-size="true" | ||||
| 						@section-click="scrollToWarnings" | ||||
| 					> | ||||
| 						<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings"> | ||||
| 							{{ round2dm(summary.Total.Supported) }}% | ||||
| 						</h2> | ||||
| 						<div class="text-body"> | ||||
| 							support | ||||
| 						</div> | ||||
| 						<div class="text-body">support</div> | ||||
| 						<template #legend> | ||||
| 							<p class="my-3 small mb-1 text-center" @click="scrollToWarnings"> | ||||
| 								<span class="text-nowrap"> | ||||
| 									<i class="bi bi-circle-fill text-success"></i> | ||||
| 									{{ round2dm(summary.Total.Supported) }}% supported | ||||
| 								</span>   | ||||
| 								</span> | ||||
| 								  | ||||
| 								<span class="text-nowrap"> | ||||
| 									<i class="bi bi-circle-fill text-warning"></i> | ||||
| 									{{ round2dm(summary.Total.Partial) }}% partially supported | ||||
| 								</span>   | ||||
| 								</span> | ||||
| 								  | ||||
| 								<span class="text-nowrap"> | ||||
| 									<i class="bi bi-circle-fill text-danger"></i> | ||||
| 									{{ round2dm(summary.Total.Unsupported) }}% not supported | ||||
| 								</span> | ||||
| 							</p> | ||||
| 							<p class="small text-muted"> | ||||
| 								calculated from {{ formatNumber(check.Total.Tests) }} tests | ||||
| 							</p> | ||||
| 							<p class="small text-muted">calculated from {{ formatNumber(check.Total.Tests) }} tests</p> | ||||
| 						</template> | ||||
| 					</vc-donut> | ||||
|  | ||||
| 					<div class="input-group justify-content-center mb-3"> | ||||
| 						<button class="btn btn-outline-secondary" data-bs-toggle="modal" | ||||
| 							data-bs-target="#AboutHTMLCheckResults"> | ||||
| 						<button | ||||
| 							class="btn btn-outline-secondary" | ||||
| 							data-bs-toggle="modal" | ||||
| 							data-bs-target="#AboutHTMLCheckResults" | ||||
| 						> | ||||
| 							<i class="bi bi-info-circle-fill"></i> | ||||
| 							Help | ||||
| 						</button> | ||||
| @@ -339,12 +367,24 @@ export default { | ||||
| 				</div> | ||||
| 				<div class="col-md"> | ||||
| 					<h2 class="h5 mb-3">Tested platforms:</h2> | ||||
| 					<div class="form-check form-switch" v-for="p, k in allPlatforms"> | ||||
| 						<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms" | ||||
| 							:aria-label="p" :id="'Check_' + k"> | ||||
| 						<label class="form-check-label" :for="'Check_' + k" | ||||
| 							:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'" :title="families(k).join(', ')" | ||||
| 							data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')"> | ||||
| 					<div v-for="(p, k) in allPlatforms" :key="'check_' + k" class="form-check form-switch"> | ||||
| 						<input | ||||
| 							:id="'Check_' + k" | ||||
| 							v-model="platforms" | ||||
| 							class="form-check-input" | ||||
| 							type="checkbox" | ||||
| 							role="switch" | ||||
| 							:value="k" | ||||
| 							:aria-label="p" | ||||
| 						/> | ||||
| 						<label | ||||
| 							class="form-check-label" | ||||
| 							:for="'Check_' + k" | ||||
| 							:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'" | ||||
| 							:title="families(k).join(', ')" | ||||
| 							data-bs-toggle="tooltip" | ||||
| 							:data-bs-title="families(k).join(', ')" | ||||
| 						> | ||||
| 							{{ p }} | ||||
| 						</label> | ||||
| 					</div> | ||||
| @@ -356,45 +396,72 @@ export default { | ||||
| 			<h4 ref="warnings" class="h5 mt-4"> | ||||
| 				{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes: | ||||
| 			</h4> | ||||
| 			<div class="accordion" id="warnings"> | ||||
| 				<div class="accordion-item" v-for="warning in summary.Warnings"> | ||||
| 			<div id="warnings" class="accordion"> | ||||
| 				<div v-for="(warning, i) in summary.Warnings" :key="'warning_' + i" class="accordion-item"> | ||||
| 					<h2 class="accordion-header"> | ||||
| 						<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 							:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug"> | ||||
| 						<button | ||||
| 							class="accordion-button collapsed" | ||||
| 							type="button" | ||||
| 							data-bs-toggle="collapse" | ||||
| 							:data-bs-target="'#' + warning.Slug" | ||||
| 							aria-expanded="false" | ||||
| 							:aria-controls="warning.Slug" | ||||
| 						> | ||||
| 							<div class="row w-100 w-lg-75"> | ||||
| 								<div class="col-sm"> | ||||
| 									{{ warning.Title }} | ||||
| 									<span class="ms-2 small badge text-bg-secondary" title="Test category"> | ||||
| 										{{ warning.Category }} | ||||
| 									</span> | ||||
| 									<span class="ms-2 small badge text-bg-light" | ||||
| 										title="The number of times this was detected"> | ||||
| 									<span | ||||
| 										class="ms-2 small badge text-bg-light" | ||||
| 										title="The number of times this was detected" | ||||
| 									> | ||||
| 										x {{ warning.Score.Found }} | ||||
| 									</span> | ||||
| 								</div> | ||||
| 								<div class="col-sm mt-2 mt-sm-0"> | ||||
| 									<div class="progress-stacked"> | ||||
| 										<div class="progress" role="progressbar" aria-label="Supported" | ||||
| 											:aria-valuenow="warning.Score.Supported" aria-valuemin="0" | ||||
| 											aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }" | ||||
| 											title="Supported"> | ||||
| 										<div | ||||
| 											class="progress" | ||||
| 											role="progressbar" | ||||
| 											aria-label="Supported" | ||||
| 											:aria-valuenow="warning.Score.Supported" | ||||
| 											aria-valuemin="0" | ||||
| 											aria-valuemax="100" | ||||
| 											:style="{ width: warning.Score.Supported + '%' }" | ||||
| 											title="Supported" | ||||
| 										> | ||||
| 											<div class="progress-bar bg-success"> | ||||
| 												{{ round(warning.Score.Supported) + '%' }} | ||||
| 												{{ round(warning.Score.Supported) + "%" }} | ||||
| 											</div> | ||||
| 										</div> | ||||
| 										<div class="progress" role="progressbar" aria-label="Partial" | ||||
| 											:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100" | ||||
| 											:style="{ width: warning.Score.Partial + '%' }" title="Partial support"> | ||||
| 										<div | ||||
| 											class="progress" | ||||
| 											role="progressbar" | ||||
| 											aria-label="Partial" | ||||
| 											:aria-valuenow="warning.Score.Partial" | ||||
| 											aria-valuemin="0" | ||||
| 											aria-valuemax="100" | ||||
| 											:style="{ width: warning.Score.Partial + '%' }" | ||||
| 											title="Partial support" | ||||
| 										> | ||||
| 											<div class="progress-bar progress-bar-striped bg-warning text-dark"> | ||||
| 												{{ round(warning.Score.Partial) + '%' }} | ||||
| 												{{ round(warning.Score.Partial) + "%" }} | ||||
| 											</div> | ||||
| 										</div> | ||||
| 										<div class="progress" role="progressbar" aria-label="No" | ||||
| 											:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" | ||||
| 											aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }" | ||||
| 											title="Not supported"> | ||||
| 										<div | ||||
| 											class="progress" | ||||
| 											role="progressbar" | ||||
| 											aria-label="No" | ||||
| 											:aria-valuenow="warning.Score.Unsupported" | ||||
| 											aria-valuemin="0" | ||||
| 											aria-valuemax="100" | ||||
| 											:style="{ width: warning.Score.Unsupported + '%' }" | ||||
| 											title="Not supported" | ||||
| 										> | ||||
| 											<div class="progress-bar bg-danger"> | ||||
| 												{{ round(warning.Score.Unsupported) + '%' }} | ||||
| 												{{ round(warning.Score.Unsupported) + "%" }} | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
| @@ -404,28 +471,45 @@ export default { | ||||
| 					</h2> | ||||
| 					<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings"> | ||||
| 						<div class="accordion-body"> | ||||
| 							<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule"> | ||||
| 							<p v-if="warning.Description !== '' || warning.PseudoClassOrAtRule"> | ||||
| 								<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2"> | ||||
| 									<i class="bi bi-info-circle me-2"></i> | ||||
| 									Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code> | ||||
| 									propert<template v-if="warning.Score.Found === 1">y</template><template | ||||
| 										v-else>ies</template> in the CSS | ||||
| 									styles, but unable to test if used or not. | ||||
| 									<template v-if="warning.Score.Found === 1">property</template> | ||||
| 									<template v-else>properties</template> | ||||
| 									in the CSS styles, but unable to test if used or not. | ||||
| 								</span> | ||||
| 								<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span> | ||||
| 								<!-- eslint-disable vue/no-v-html --> | ||||
| 								<span | ||||
| 									v-if="warning.Description !== ''" | ||||
| 									class="me-2" | ||||
| 									v-html="sanitizeHTML(warning.Description)" | ||||
| 								></span> | ||||
| 								<!-- -eslint-disable vue/no-v-html --> | ||||
| 							</p> | ||||
|  | ||||
| 							<template v-if="warning.Results.length"> | ||||
| 								<h3 class="h6">Clients with partial or no support:</h3> | ||||
| 								<p> | ||||
| 									<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4"> | ||||
| 										<i class="bi bi-circle-fill" | ||||
| 											:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'" | ||||
| 											:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i> | ||||
| 										{{ warning.Name }} | ||||
| 										<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''" | ||||
| 											title="See notes"> | ||||
| 											{{ warning.NoteNumber }} | ||||
| 									<small | ||||
| 										v-for="(warningRes, wi) in warning.Results" | ||||
| 										:key="'warning_results_' + wi" | ||||
| 										class="text-nowrap d-inline-block me-4" | ||||
| 									> | ||||
| 										<i | ||||
| 											class="bi bi-circle-fill" | ||||
| 											:class="warningRes.Support === 'no' ? 'text-danger' : 'text-warning'" | ||||
| 											:title=" | ||||
| 												warningRes.Support === 'no' ? 'Not supported' : 'Partially supported' | ||||
| 											" | ||||
| 										></i> | ||||
| 										{{ warningRes.Name }} | ||||
| 										<span | ||||
| 											v-if="warningRes.NoteNumber !== ''" | ||||
| 											class="badge text-bg-secondary" | ||||
| 											title="See notes" | ||||
| 										> | ||||
| 											{{ warningRes.NoteNumber }} | ||||
| 										</span> | ||||
| 									</small> | ||||
| 								</p> | ||||
| @@ -433,17 +517,21 @@ export default { | ||||
|  | ||||
| 							<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3"> | ||||
| 								<h3 class="h6">Notes:</h3> | ||||
| 								<div v-for="n, i in warning.NotesByNumber" class="small row my-2"> | ||||
| 								<div | ||||
| 									v-for="(n, ni) in warning.NotesByNumber" | ||||
| 									:key="'warning_notes' + ni" | ||||
| 									class="small row my-2" | ||||
| 								> | ||||
| 									<div class="col-auto pe-0"> | ||||
| 										<span class="badge text-bg-secondary"> | ||||
| 											{{ i }} | ||||
| 											{{ ni }} | ||||
| 										</span> | ||||
| 									</div> | ||||
| 									<div class="col" v-html="n"></div> | ||||
| 									<div class="col" v-html="sanitizeHTML(n)"></div> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  | ||||
| 							<p class="small mt-3 mb-0" v-if="warning.URL"> | ||||
| 							<p v-if="warning.URL" class="small mt-3 mb-0"> | ||||
| 								<a :href="warning.URL" target="_blank">Online reference</a> | ||||
| 							</p> | ||||
| 						</div> | ||||
| @@ -452,30 +540,44 @@ export default { | ||||
| 			</div> | ||||
|  | ||||
| 			<p class="text-center text-muted small mt-4"> | ||||
| 				Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using | ||||
| 				compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>. | ||||
| 				Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using compatibility data | ||||
| 				from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>. | ||||
| 			</p> | ||||
| 		</template> | ||||
|  | ||||
| 		<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel" | ||||
| 			aria-hidden="true"> | ||||
| 		<div | ||||
| 			id="AboutHTMLCheckResults" | ||||
| 			class="modal fade" | ||||
| 			tabindex="-1" | ||||
| 			aria-labelledby="AboutHTMLCheckResultsLabel" | ||||
| 			aria-hidden="true" | ||||
| 		> | ||||
| 			<div class="modal-dialog modal-lg modal-dialog-scrollable"> | ||||
| 				<div class="modal-content"> | ||||
| 					<div class="modal-header"> | ||||
| 						<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1> | ||||
| 						<h1 id="AboutHTMLCheckResultsLabel" class="modal-title fs-5">About HTML check</h1> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						<div class="accordion" id="HTMLCheckAboutAccordion"> | ||||
| 						<div id="HTMLCheckAboutAccordion" class="accordion"> | ||||
| 							<div class="accordion-item"> | ||||
| 								<h2 class="accordion-header"> | ||||
| 									<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col1" aria-expanded="false" aria-controls="col1"> | ||||
| 									<button | ||||
| 										class="accordion-button collapsed" | ||||
| 										type="button" | ||||
| 										data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col1" | ||||
| 										aria-expanded="false" | ||||
| 										aria-controls="col1" | ||||
| 									> | ||||
| 										What is HTML check? | ||||
| 									</button> | ||||
| 								</h2> | ||||
| 								<div id="col1" class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion"> | ||||
| 								<div | ||||
| 									id="col1" | ||||
| 									class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion" | ||||
| 								> | ||||
| 									<div class="accordion-body"> | ||||
| 										The support for HTML/CSS messages varies greatly across email clients. HTML | ||||
| 										check attempts to calculate the overall support for your email for all selected | ||||
| @@ -485,13 +587,22 @@ export default { | ||||
| 							</div> | ||||
| 							<div class="accordion-item"> | ||||
| 								<h2 class="accordion-header"> | ||||
| 									<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col2" aria-expanded="false" aria-controls="col2"> | ||||
| 									<button | ||||
| 										class="accordion-button collapsed" | ||||
| 										type="button" | ||||
| 										data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col2" | ||||
| 										aria-expanded="false" | ||||
| 										aria-controls="col2" | ||||
| 									> | ||||
| 										How does it work? | ||||
| 									</button> | ||||
| 								</h2> | ||||
| 								<div id="col2" class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion"> | ||||
| 								<div | ||||
| 									id="col2" | ||||
| 									class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion" | ||||
| 								> | ||||
| 									<div class="accordion-body"> | ||||
| 										<p> | ||||
| 											Internally the original HTML message is run against | ||||
| @@ -504,10 +615,11 @@ export default { | ||||
| 											CSS support is very difficult to programmatically test, especially if a | ||||
| 											message contains CSS style blocks or is linked to remote stylesheets. Remote | ||||
| 											stylesheets are, unless blocked via | ||||
| 											<code>--block-remote-css-and-fonts</code>, | ||||
| 											downloaded and injected into the message as style blocks. The email is then | ||||
| 											<a href="https://github.com/vanng822/go-premailer" | ||||
| 												target="_blank">inlined</a> | ||||
| 											<code>--block-remote-css-and-fonts</code>, downloaded and injected into the | ||||
| 											message as style blocks. The email is then | ||||
| 											<a href="https://github.com/vanng822/go-premailer" target="_blank" | ||||
| 												>inlined</a | ||||
| 											> | ||||
| 											to matching HTML elements. This gives Mailpit fairly accurate results. | ||||
| 										</p> | ||||
| 										<p> | ||||
| @@ -528,13 +640,22 @@ export default { | ||||
| 							</div> | ||||
| 							<div class="accordion-item"> | ||||
| 								<h2 class="accordion-header"> | ||||
| 									<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col3" aria-expanded="false" aria-controls="col3"> | ||||
| 									<button | ||||
| 										class="accordion-button collapsed" | ||||
| 										type="button" | ||||
| 										data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col3" | ||||
| 										aria-expanded="false" | ||||
| 										aria-controls="col3" | ||||
| 									> | ||||
| 										Is the final score accurate? | ||||
| 									</button> | ||||
| 								</h2> | ||||
| 								<div id="col3" class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion"> | ||||
| 								<div | ||||
| 									id="col3" | ||||
| 									class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion" | ||||
| 								> | ||||
| 									<div class="accordion-body"> | ||||
| 										<p> | ||||
| 											There are many ways to define "accurate", and how one should calculate the | ||||
| @@ -578,13 +699,22 @@ export default { | ||||
|  | ||||
| 							<div class="accordion-item"> | ||||
| 								<h2 class="accordion-header"> | ||||
| 									<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col4" aria-expanded="false" aria-controls="col4"> | ||||
| 									<button | ||||
| 										class="accordion-button collapsed" | ||||
| 										type="button" | ||||
| 										data-bs-toggle="collapse" | ||||
| 										data-bs-target="#col4" | ||||
| 										aria-expanded="false" | ||||
| 										aria-controls="col4" | ||||
| 									> | ||||
| 										What about invalid HTML? | ||||
| 									</button> | ||||
| 								</h2> | ||||
| 								<div id="col4" class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion"> | ||||
| 								<div | ||||
| 									id="col4" | ||||
| 									class="accordion-collapse collapse" | ||||
| 									data-bs-parent="#HTMLCheckAboutAccordion" | ||||
| 								> | ||||
| 									<div class="accordion-body"> | ||||
| 										HTML check does not detect if the original HTML is valid. In order to detect | ||||
| 										applied styles to every node, the HTML email is run through a parser which is | ||||
| @@ -592,7 +722,6 @@ export default { | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
|   | ||||
| @@ -1,36 +0,0 @@ | ||||
| <script> | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         message: Object | ||||
|     }, | ||||
|  | ||||
|     mixins: [commonMixins], | ||||
|  | ||||
|     data() { | ||||
|         return { | ||||
|             headers: false | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     mounted() { | ||||
|         let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers') | ||||
|         this.get(uri, false, (response) => { | ||||
|             this.headers = response.data | ||||
|         }); | ||||
|     }, | ||||
|  | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div v-if="headers" class="small"> | ||||
|         <div v-for="values, k in headers" class="row mb-2 pb-2 border-bottom w-100"> | ||||
|             <div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div> | ||||
|             <div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary"> | ||||
|                 <div v-for="x in values" class="mb-2 text-break">{{ x }}</div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
| @@ -1,16 +1,19 @@ | ||||
| <script> | ||||
| import axios from 'axios' | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import axios from "axios"; | ||||
| import commonMixins from "../../mixins/CommonMixins"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["setLinkErrors"], | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			error: false, | ||||
| @@ -19,116 +22,116 @@ export default { | ||||
| 			check: false, | ||||
| 			loaded: false, | ||||
| 			loading: false, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	created() { | ||||
| 		this.autoScan = localStorage.getItem('LinkCheckAutoScan') | ||||
| 		this.followRedirects = localStorage.getItem('LinkCheckFollowRedirects') | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.loaded = true | ||||
| 		if (this.autoScan) { | ||||
| 			this.doCheck() | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		autoScan(v) { | ||||
| 			if (!this.loaded) { | ||||
| 				return | ||||
| 			} | ||||
| 			if (v) { | ||||
| 				localStorage.setItem('LinkCheckAutoScan', true) | ||||
| 				if (!this.check) { | ||||
| 					this.doCheck() | ||||
| 				} | ||||
| 			} else { | ||||
| 				localStorage.removeItem('LinkCheckAutoScan') | ||||
| 			} | ||||
| 		}, | ||||
| 		followRedirects(v) { | ||||
| 			if (!this.loaded) { | ||||
| 				return | ||||
| 			} | ||||
| 			if (v) { | ||||
| 				localStorage.setItem('LinkCheckFollowRedirects', true) | ||||
| 			} else { | ||||
| 				localStorage.removeItem('LinkCheckFollowRedirects') | ||||
| 			} | ||||
| 			if (this.check) { | ||||
| 				this.doCheck() | ||||
| 			} | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		groupedStatuses() { | ||||
| 			let results = {} | ||||
| 			const results = {}; | ||||
|  | ||||
| 			if (!this.check) { | ||||
| 				return results | ||||
| 				return results; | ||||
| 			} | ||||
|  | ||||
| 			// group by status | ||||
| 			this.check.Links.forEach(function (r) { | ||||
| 			this.check.Links.forEach((r) => { | ||||
| 				if (!results[r.StatusCode]) { | ||||
| 					let css = "" | ||||
| 					let css = ""; | ||||
| 					if (r.StatusCode >= 400 || r.StatusCode === 0) { | ||||
| 						css = "text-danger" | ||||
| 						css = "text-danger"; | ||||
| 					} else if (r.StatusCode >= 300) { | ||||
| 						css = "text-info" | ||||
| 						css = "text-info"; | ||||
| 					} | ||||
|  | ||||
| 					if (r.StatusCode === 0) { | ||||
| 						r.Status = 'Cannot connect to server' | ||||
| 						r.Status = "Cannot connect to server"; | ||||
| 					} | ||||
| 					results[r.StatusCode] = { | ||||
| 						StatusCode: r.StatusCode, | ||||
| 						Status: r.Status, | ||||
| 						Class: css, | ||||
| 						URLS: [] | ||||
| 					} | ||||
| 						URLS: [], | ||||
| 					}; | ||||
| 				} | ||||
| 				results[r.StatusCode].URLS.push(r.URL) | ||||
| 			}) | ||||
| 				results[r.StatusCode].URLS.push(r.URL); | ||||
| 			}); | ||||
|  | ||||
| 			let newArr = [] | ||||
| 			const newArr = []; | ||||
|  | ||||
| 			for (const i in results) { | ||||
| 				newArr.push(results[i]) | ||||
| 				newArr.push(results[i]); | ||||
| 			} | ||||
|  | ||||
| 			// sort statuses | ||||
| 			let sorted = newArr.sort((a, b) => { | ||||
| 			const sorted = newArr.sort((a, b) => { | ||||
| 				if (a.StatusCode === 0) { | ||||
| 					return false | ||||
| 					return false; | ||||
| 				} | ||||
| 				return a.StatusCode < b.StatusCode | ||||
| 			}) | ||||
| 				return a.StatusCode < b.StatusCode; | ||||
| 			}); | ||||
|  | ||||
| 			return sorted; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 			return sorted | ||||
| 	watch: { | ||||
| 		autoScan(v) { | ||||
| 			if (!this.loaded) { | ||||
| 				return; | ||||
| 			} | ||||
| 			if (v) { | ||||
| 				localStorage.setItem("LinkCheckAutoScan", true); | ||||
| 				if (!this.check) { | ||||
| 					this.doCheck(); | ||||
| 				} | ||||
| 			} else { | ||||
| 				localStorage.removeItem("LinkCheckAutoScan"); | ||||
| 			} | ||||
| 		}, | ||||
| 		followRedirects(v) { | ||||
| 			if (!this.loaded) { | ||||
| 				return; | ||||
| 			} | ||||
| 			if (v) { | ||||
| 				localStorage.setItem("LinkCheckFollowRedirects", true); | ||||
| 			} else { | ||||
| 				localStorage.removeItem("LinkCheckFollowRedirects"); | ||||
| 			} | ||||
| 			if (this.check) { | ||||
| 				this.doCheck(); | ||||
| 			} | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	created() { | ||||
| 		this.autoScan = localStorage.getItem("LinkCheckAutoScan"); | ||||
| 		this.followRedirects = localStorage.getItem("LinkCheckFollowRedirects"); | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.loaded = true; | ||||
| 		if (this.autoScan) { | ||||
| 			this.doCheck(); | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		doCheck() { | ||||
| 			this.check = false | ||||
| 			this.loading = true | ||||
| 			let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check') | ||||
| 			this.check = false; | ||||
| 			this.loading = true; | ||||
| 			let uri = this.resolve("/api/v1/message/" + this.message.ID + "/link-check"); | ||||
| 			if (this.followRedirects) { | ||||
| 				uri += '?follow=true' | ||||
| 				uri += "?follow=true"; | ||||
| 			} | ||||
|  | ||||
| 			// ignore any error, do not show loader | ||||
| 			axios.get(uri, null) | ||||
| 			axios | ||||
| 				.get(uri, null) | ||||
| 				.then((result) => { | ||||
| 					this.check = result.data | ||||
| 					this.error = false | ||||
| 					this.check = result.data; | ||||
| 					this.error = false; | ||||
|  | ||||
| 					this.$emit('setLinkErrors', result.data.Errors) | ||||
| 					this.$emit("setLinkErrors", result.data.Errors); | ||||
| 				}) | ||||
| 				.catch((error) => { | ||||
| 					// handle error | ||||
| @@ -136,27 +139,27 @@ export default { | ||||
| 						// The request was made and the server responded with a status code | ||||
| 						// that falls out of the range of 2xx | ||||
| 						if (error.response.data.Error) { | ||||
| 							this.error = error.response.data.Error | ||||
| 							this.error = error.response.data.Error; | ||||
| 						} else { | ||||
| 							this.error = error.response.data | ||||
| 							this.error = error.response.data; | ||||
| 						} | ||||
| 					} else if (error.request) { | ||||
| 						// The request was made but no response was received | ||||
| 						// `error.request` is an instance of XMLHttpRequest in the browser and an instance of | ||||
| 						// http.ClientRequest in node.js | ||||
| 						this.error = 'Error sending data to the server. Please try again.' | ||||
| 						this.error = "Error sending data to the server. Please try again."; | ||||
| 					} else { | ||||
| 						// Something happened in setting up the request that triggered an Error | ||||
| 						this.error = error.message | ||||
| 						this.error = error.message; | ||||
| 					} | ||||
| 				}) | ||||
| 				.then((result) => { | ||||
| 					// always run | ||||
| 					this.loading = false | ||||
| 				}) | ||||
| 					this.loading = false; | ||||
| 				}); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -164,24 +167,24 @@ export default { | ||||
| 		<div class="row mb-3 align-items-center"> | ||||
| 			<div class="col"> | ||||
| 				<h4 class="mb-0"> | ||||
| 					<template v-if="!check"> | ||||
| 						Link check | ||||
| 					</template> | ||||
| 					<template v-if="!check"> Link check </template> | ||||
| 					<template v-else> | ||||
| 						<template v-if="check.Links.length"> | ||||
| 							Scanned {{ formatNumber(check.Links.length) }} | ||||
| 							link<template v-if="check.Links.length != 1">s</template> | ||||
| 						</template> | ||||
| 						<template v-else> | ||||
| 							No links detected | ||||
| 							Scanned {{ formatNumber(check.Links.length) }} link<template v-if="check.Links.length != 1" | ||||
| 								>s</template | ||||
| 							> | ||||
| 						</template> | ||||
| 						<template v-else> No links detected </template> | ||||
| 					</template> | ||||
| 				</h4> | ||||
| 			</div> | ||||
| 			<div class="col-auto"> | ||||
| 				<div class="input-group"> | ||||
| 					<button class="btn btn-outline-secondary" data-bs-toggle="modal" | ||||
| 						data-bs-target="#AboutLinkCheckResults"> | ||||
| 					<button | ||||
| 						class="btn btn-outline-secondary" | ||||
| 						data-bs-toggle="modal" | ||||
| 						data-bs-target="#AboutLinkCheckResults" | ||||
| 					> | ||||
| 						<i class="bi bi-info-circle-fill"></i> | ||||
| 						Help | ||||
| 					</button> | ||||
| @@ -195,12 +198,12 @@ export default { | ||||
|  | ||||
| 		<div v-if="!check"> | ||||
| 			<p class="text-muted"> | ||||
| 				Link check scans your email text & HTML for unique links, testing the response status codes. | ||||
| 				This includes links to images and remote CSS stylesheets. | ||||
| 				Link check scans your email text & HTML for unique links, testing the response status codes. This | ||||
| 				includes links to images and remote CSS stylesheets. | ||||
| 			</p> | ||||
|  | ||||
| 			<p class="text-center my-5"> | ||||
| 				<button v-if="!check" class="btn btn-primary btn-lg" @click="doCheck()" :disabled="loading"> | ||||
| 				<button v-if="!check" class="btn btn-primary btn-lg" :disabled="loading" @click="doCheck()"> | ||||
| 					<template v-if="loading"> | ||||
| 						Checking links | ||||
| 						<div class="ms-1 spinner-border spinner-border-sm text-light" role="status"> | ||||
| @@ -215,14 +218,14 @@ export default { | ||||
| 			</p> | ||||
| 		</div> | ||||
|  | ||||
| 		<div v-else v-for="s, k in groupedStatuses"> | ||||
| 		<div v-for="(s, k) in groupedStatuses" v-else :key="k"> | ||||
| 			<div class="card mb-3"> | ||||
| 				<div class="card-header h4" :class="s.Class"> | ||||
| 					Status {{ s.StatusCode }} | ||||
| 					<small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small> | ||||
| 				</div> | ||||
| 				<ul class="list-group list-group-flush"> | ||||
| 					<li v-for="u in s.URLS" class="list-group-item"> | ||||
| 					<li v-for="(u, i) in s.URLS" :key="'status' + i" class="list-group-item"> | ||||
| 						<a :href="u" target="_blank" class="no-icon">{{ u }}</a> | ||||
| 					</li> | ||||
| 				</ul> | ||||
| @@ -235,22 +238,31 @@ export default { | ||||
| 				{{ error }} | ||||
| 			</div> | ||||
| 		</template> | ||||
|  | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel" | ||||
| 		aria-hidden="true"> | ||||
| 	<div | ||||
| 		id="LinkCheckOptions" | ||||
| 		class="modal fade" | ||||
| 		tabindex="-1" | ||||
| 		aria-labelledby="LinkCheckOptionsLabel" | ||||
| 		aria-hidden="true" | ||||
| 	> | ||||
| 		<div class="modal-dialog modal-lg modal-dialog-scrollable"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h1 class="modal-title fs-5" id="LinkCheckOptionsLabel">Link check options</h1> | ||||
| 					<h1 id="LinkCheckOptionsLabel" class="modal-title fs-5">Link check options</h1> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6> | ||||
| 					<div class="form-check form-switch mb-4"> | ||||
| 						<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects" | ||||
| 							id="LinkCheckFollowRedirectsSwitch"> | ||||
| 						<input | ||||
| 							id="LinkCheckFollowRedirectsSwitch" | ||||
| 							v-model="followRedirects" | ||||
| 							class="form-check-input" | ||||
| 							type="checkbox" | ||||
| 							role="switch" | ||||
| 						/> | ||||
| 						<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch"> | ||||
| 							<template v-if="followRedirects">Following HTTP redirects</template> | ||||
| 							<template v-else>Not following HTTP redirects</template> | ||||
| @@ -259,8 +271,13 @@ export default { | ||||
|  | ||||
| 					<h6 class="mt-4">Automatic link checking</h6> | ||||
| 					<div class="form-check form-switch mb-3"> | ||||
| 						<input class="form-check-input" type="checkbox" role="switch" v-model="autoScan" | ||||
| 							id="LinkCheckAutoCheckSwitch"> | ||||
| 						<input | ||||
| 							id="LinkCheckAutoCheckSwitch" | ||||
| 							v-model="autoScan" | ||||
| 							class="form-check-input" | ||||
| 							type="checkbox" | ||||
| 							role="switch" | ||||
| 						/> | ||||
| 						<label class="form-check-label" for="LinkCheckAutoCheckSwitch"> | ||||
| 							<template v-if="autoScan">Automatic link checking is enabled</template> | ||||
| 							<template v-else>Automatic link checking is disabled</template> | ||||
| @@ -270,7 +287,6 @@ export default { | ||||
| 							Only enable this if you understand the potential risks & consequences. | ||||
| 						</div> | ||||
| 					</div> | ||||
|  | ||||
| 				</div> | ||||
| 				<div class="modal-footer"> | ||||
| 					<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> | ||||
| @@ -279,25 +295,39 @@ export default { | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="modal fade" id="AboutLinkCheckResults" tabindex="-1" aria-labelledby="AboutLinkCheckResultsLabel" | ||||
| 		aria-hidden="true"> | ||||
| 	<div | ||||
| 		id="AboutLinkCheckResults" | ||||
| 		class="modal fade" | ||||
| 		tabindex="-1" | ||||
| 		aria-labelledby="AboutLinkCheckResultsLabel" | ||||
| 		aria-hidden="true" | ||||
| 	> | ||||
| 		<div class="modal-dialog modal-lg modal-dialog-scrollable"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h1 class="modal-title fs-5" id="AboutLinkCheckResultsLabel">About Link check</h1> | ||||
| 					<h1 id="AboutLinkCheckResultsLabel" class="modal-title fs-5">About Link check</h1> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<div class="accordion" id="LinkCheckAboutAccordion"> | ||||
| 					<div id="LinkCheckAboutAccordion" class="accordion"> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col1" aria-expanded="false" aria-controls="col1"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col1" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col1" | ||||
| 								> | ||||
| 									What is Link check? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col1" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col1" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									Link check scans your message HTML and text for all unique links, images and linked | ||||
| 									stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a | ||||
| @@ -307,35 +337,52 @@ export default { | ||||
| 						</div> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col2" aria-expanded="false" aria-controls="col2"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col2" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col2" | ||||
| 								> | ||||
| 									What are "301" and "302" links? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col2" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col2" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									<p> | ||||
| 										These are links that redirect you to another URL, for example newsletters | ||||
| 										often use redirect links to track user clicks. | ||||
| 										These are links that redirect you to another URL, for example newsletters often | ||||
| 										use redirect links to track user clicks. | ||||
| 									</p> | ||||
| 									<p> | ||||
| 										By default Link check will not follow these links, however you can turn this on | ||||
| 										via | ||||
| 										the settings and Link check will "follow" those redirects. | ||||
| 										via the settings and Link check will "follow" those redirects. | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col3" aria-expanded="false" aria-controls="col3"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col3" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col3" | ||||
| 								> | ||||
| 									Why are some links returning an error but work in my browser? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col3" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col3" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									<p>This may be due to various reasons, for instance:</p> | ||||
| 									<ul> | ||||
| @@ -345,20 +392,29 @@ export default { | ||||
| 											The webserver is blocking requests that don't come from authenticated web | ||||
| 											browsers. | ||||
| 										</li> | ||||
| 										<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests. </li> | ||||
| 										<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests.</li> | ||||
| 									</ul> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col4" aria-expanded="false" aria-controls="col4"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col4" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col4" | ||||
| 								> | ||||
| 									What are the risks of running Link check automatically? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col4" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col4" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#LinkCheckAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									<p> | ||||
| 										Depending on the type of messages you are testing, opening all links on all | ||||
| @@ -382,7 +438,6 @@ export default { | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
|  | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="modal-footer"> | ||||
|   | ||||
| @@ -1,657 +0,0 @@ | ||||
| <script> | ||||
| import Attachments from './Attachments.vue' | ||||
| import Headers from './Headers.vue' | ||||
| import HTMLCheck from './HTMLCheck.vue' | ||||
| import LinkCheck from './LinkCheck.vue' | ||||
| import SpamAssassin from './SpamAssassin.vue' | ||||
| import Tags from 'bootstrap5-tags' | ||||
| import { Tooltip } from 'bootstrap' | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import { mailbox } from '../../stores/mailbox' | ||||
| import DOMPurify from 'dompurify' | ||||
| import hljs from 'highlight.js/lib/core' | ||||
| import xml from 'highlight.js/lib/languages/xml' | ||||
|  | ||||
| hljs.registerLanguage('html', xml) | ||||
|  | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 	}, | ||||
|  | ||||
| 	components: { | ||||
| 		Attachments, | ||||
| 		Headers, | ||||
| 		HTMLCheck, | ||||
| 		LinkCheck, | ||||
| 		SpamAssassin, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			srcURI: false, | ||||
| 			iframes: [], // for resizing | ||||
| 			canSaveTags: false, // prevent auto-saving tags on render | ||||
| 			availableTags: [], | ||||
| 			messageTags: [], | ||||
| 			loadHeaders: false, | ||||
| 			htmlScore: false, | ||||
| 			htmlScoreColor: false, | ||||
| 			linkCheckErrors: false, | ||||
| 			spamScore: false, | ||||
| 			spamScoreColor: false, | ||||
| 			showMobileButtons: false, | ||||
| 			showUnsubscribe: false, | ||||
| 			scaleHTMLPreview: 'display', | ||||
| 			// keys names match bootstrap icon names  | ||||
| 			responsiveSizes: { | ||||
| 				phone: 'width: 322px; height: 570px', | ||||
| 				tablet: 'width: 768px; height: 1024px', | ||||
| 				display: 'width: 100%; height: 100%', | ||||
| 			}, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		messageTags() { | ||||
| 			if (this.canSaveTags) { | ||||
| 				// save changes to tags | ||||
| 				this.saveTags() | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		scaleHTMLPreview(v) { | ||||
| 			if (v == 'display') { | ||||
| 				window.setTimeout(() => { | ||||
| 					this.resizeIFrames() | ||||
| 				}, 500) | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		hasAnyChecksEnabled() { | ||||
| 			return (mailbox.showHTMLCheck && this.message.HTML) | ||||
| 				|| mailbox.showLinkCheck | ||||
| 				|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin) | ||||
| 		}, | ||||
|  | ||||
| 		// remove bad HTML, JavaScript, iframes etc | ||||
| 		sanitizedHTML() { | ||||
| 			// set target & rel on all links | ||||
| 			DOMPurify.addHook('afterSanitizeAttributes', (node) => { | ||||
| 				if (node.tagName != 'A' || (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#')) { | ||||
| 					return | ||||
| 				} | ||||
| 				if ('target' in node) { | ||||
| 					node.setAttribute('target', '_blank'); | ||||
| 					node.setAttribute('rel', 'noopener noreferrer'); | ||||
| 				} | ||||
| 				if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) { | ||||
| 					node.setAttribute('xlink:show', '_blank'); | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| 			const clean = DOMPurify.sanitize( | ||||
| 				this.message.HTML, | ||||
| 				{ | ||||
| 					WHOLE_DOCUMENT: true, | ||||
| 					SANITIZE_DOM: false, | ||||
| 					ADD_TAGS: [ | ||||
| 						'link', | ||||
| 						'meta', | ||||
| 						'o:p', | ||||
| 						'style', | ||||
| 					], | ||||
| 					ADD_ATTR: [ | ||||
| 						'bordercolor', | ||||
| 						'charset', | ||||
| 						'content', | ||||
| 						'hspace', | ||||
| 						'http-equiv', | ||||
| 						'itemprop', | ||||
| 						'itemscope', | ||||
| 						'itemtype', | ||||
| 						'link', | ||||
| 						'vertical-align', | ||||
| 						'vlink', | ||||
| 						'vspace', | ||||
| 						'xml:lang', | ||||
| 					], | ||||
| 					FORBID_ATTR: ['script'], | ||||
| 				} | ||||
| 			) | ||||
|  | ||||
| 			// for debugging | ||||
| 			// this.debugDOMPurify(DOMPurify.removed) | ||||
|  | ||||
| 			return clean | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.canSaveTags = false | ||||
| 		this.messageTags = this.message.Tags | ||||
| 		this.renderUI() | ||||
|  | ||||
| 		window.addEventListener("resize", this.resizeIFrames) | ||||
|  | ||||
| 		let headersTab = document.getElementById('nav-headers-tab') | ||||
| 		headersTab.addEventListener('shown.bs.tab', (event) => { | ||||
| 			this.loadHeaders = true | ||||
| 		}) | ||||
|  | ||||
| 		let rawTab = document.getElementById('nav-raw-tab') | ||||
| 		rawTab.addEventListener('shown.bs.tab', (event) => { | ||||
| 			this.srcURI = this.resolve('/api/v1/message/' + this.message.ID + '/raw') | ||||
| 			this.resizeIFrames() | ||||
| 		}) | ||||
|  | ||||
| 		// manually refresh tags | ||||
| 		this.get(this.resolve(`/api/v1/tags`), false, (response) => { | ||||
| 			this.availableTags = response.data | ||||
| 			this.$nextTick(() => { | ||||
| 				Tags.init('select[multiple]') | ||||
| 				// delay tag change detection to allow Tags to load | ||||
| 				window.setTimeout(() => { | ||||
| 					this.canSaveTags = true | ||||
| 				}, 200) | ||||
| 			}) | ||||
| 		}) | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		isHTMLTabSelected() { | ||||
| 			this.showMobileButtons = this.$refs.navhtml | ||||
| 				&& this.$refs.navhtml.classList.contains('active') | ||||
| 		}, | ||||
|  | ||||
| 		renderUI() { | ||||
| 			// activate the first non-disabled tab | ||||
| 			document.querySelector('#nav-tab button:not([disabled])').click() | ||||
| 			document.activeElement.blur() // blur focus | ||||
| 			document.getElementById('message-view').scrollTop = 0 | ||||
|  | ||||
| 			this.isHTMLTabSelected() | ||||
|  | ||||
| 			document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => { | ||||
| 				listObj.addEventListener('shown.bs.tab', (event) => { | ||||
| 					this.isHTMLTabSelected() | ||||
| 				}) | ||||
| 			}) | ||||
|  | ||||
| 			const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); | ||||
| 			[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) | ||||
|  | ||||
| 			// delay 0.5s until vue has rendered the iframe content | ||||
| 			window.setTimeout(() => { | ||||
| 				let p = document.getElementById('preview-html') | ||||
| 				if (p && typeof p.contentWindow.document.body == 'object') { | ||||
| 					try { | ||||
| 						// make links open in new window | ||||
| 						let anchorEls = p.contentWindow.document.body.querySelectorAll('a') | ||||
| 						for (var i = 0; i < anchorEls.length; i++) { | ||||
| 							let anchorEl = anchorEls[i] | ||||
| 							let href = anchorEl.getAttribute('href') | ||||
|  | ||||
| 							if (href && href.match(/^https?:\/\//i)) { | ||||
| 								anchorEl.setAttribute('target', '_blank') | ||||
| 							} | ||||
| 						} | ||||
| 					} catch (error) { } | ||||
| 					this.resizeIFrames() | ||||
| 				} | ||||
| 			}, 500) | ||||
|  | ||||
| 			// HTML highlighting | ||||
| 			hljs.highlightAll() | ||||
| 		}, | ||||
|  | ||||
| 		resizeIframe(el) { | ||||
| 			let i = el.target | ||||
| 			if (typeof i.contentWindow.document.body.scrollHeight == 'number') { | ||||
| 				i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px' | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		resizeIFrames() { | ||||
| 			if (this.scaleHTMLPreview != 'display') { | ||||
| 				return | ||||
| 			} | ||||
| 			let h = document.getElementById('preview-html') | ||||
| 			if (h) { | ||||
| 				if (typeof h.contentWindow.document.body.scrollHeight == 'number') { | ||||
| 					h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px' | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		}, | ||||
|  | ||||
| 		// set the iframe body & text colors based on current theme | ||||
| 		initRawIframe(el) { | ||||
| 			let bodyStyles = window.getComputedStyle(document.body, null) | ||||
| 			let bg = bodyStyles.getPropertyValue('background-color') | ||||
| 			let txt = bodyStyles.getPropertyValue('color') | ||||
|  | ||||
| 			let body = el.target.contentWindow.document.querySelector('body') | ||||
| 			if (body) { | ||||
| 				body.style.color = txt | ||||
| 				body.style.backgroundColor = bg | ||||
| 			} | ||||
|  | ||||
| 			this.resizeIframe(el) | ||||
| 		}, | ||||
|  | ||||
| 		// this function is unused but kept here to use for debugging | ||||
| 		debugDOMPurify(removed) { | ||||
| 			if (!removed.length) { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			const ignoreNodes = ['target', 'base', 'script', 'v:shapes'] | ||||
|  | ||||
| 			let d = removed.filter((r) => { | ||||
| 				if (typeof r.attribute != 'undefined' && | ||||
| 					(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith('xmlns:')) | ||||
| 				) { | ||||
| 					return false | ||||
| 				} | ||||
| 				// inline comments | ||||
| 				if (typeof r.element != 'undefined' && (r.element.nodeType == 8 || r.element.tagName == 'SCRIPT')) { | ||||
| 					return false | ||||
| 				} | ||||
|  | ||||
| 				return true | ||||
| 			}) | ||||
|  | ||||
| 			if (d.length) { | ||||
| 				console.log(d) | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		saveTags() { | ||||
| 			var data = { | ||||
| 				IDs: [this.message.ID], | ||||
| 				Tags: this.messageTags | ||||
| 			} | ||||
|  | ||||
| 			this.put(this.resolve('/api/v1/tags'), data, (response) => { | ||||
| 				window.scrollInPlace = true | ||||
| 				this.$emit('loadMessages') | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		// Convert plain text to HTML including anchor links | ||||
| 		textToHTML(s) { | ||||
| 			let html = s | ||||
|  | ||||
| 			// full links with http(s) | ||||
| 			let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim | ||||
| 			html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲') | ||||
|  | ||||
| 			// plain www links without https?:// prefix | ||||
| 			let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim | ||||
| 			html = html.replace(re2, '$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲') | ||||
|  | ||||
| 			// escape to HTML & convert <>" back | ||||
| 			html = html | ||||
| 				.replace(/&/g, "&") | ||||
| 				.replace(/</g, "<") | ||||
| 				.replace(/>/g, ">") | ||||
| 				.replace(/"/g, """) | ||||
| 				.replace(/'/g, "'") | ||||
| 				.replace(/˱˱˱/g, '<') | ||||
| 				.replace(/˲˲˲/g, '>') | ||||
| 				.replace(/ˠˠˠ/g, '"') | ||||
|  | ||||
| 			return html | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100"> | ||||
| 		<div class="row w-100"> | ||||
| 			<div class="col-md"> | ||||
| 				<table class="messageHeaders"> | ||||
| 					<tbody> | ||||
| 						<tr> | ||||
| 							<th class="small">From</th> | ||||
| 							<td class="privacy"> | ||||
| 								<span v-if="message.From"> | ||||
| 									<span v-if="message.From.Name" class="text-spaces"> | ||||
| 										{{ message.From.Name + " " }} | ||||
| 									</span> | ||||
| 									<span v-if="message.From.Address" class="small"> | ||||
| 										<<a :href="searchURI(message.From.Address)" class="text-body"> | ||||
| 											{{ message.From.Address }} | ||||
| 										</a>> | ||||
| 									</span> | ||||
| 								</span> | ||||
| 								<span v-else> | ||||
| 									[ Unknown ] | ||||
| 								</span> | ||||
|  | ||||
| 								<span v-if="message.ListUnsubscribe.Header != ''" class="small ms-3 link" | ||||
| 									:title="showUnsubscribe ? 'Hide unsubscribe information' : 'Show unsubscribe information'" | ||||
| 									@click="showUnsubscribe = !showUnsubscribe"> | ||||
| 									Unsubscribe | ||||
| 									<i class="bi bi bi-info-circle" | ||||
| 										:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"></i> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr class="small"> | ||||
| 							<th>To</th> | ||||
| 							<td class="privacy"> | ||||
| 								<span v-if="message.To && message.To.length" v-for="(t, i) in message.To"> | ||||
| 									<template v-if="i > 0">, </template> | ||||
| 									<span> | ||||
| 										<span class="text-spaces">{{ t.Name }}</span> | ||||
| 										<<a :href="searchURI(t.Address)" class="text-body"> | ||||
| 											{{ t.Address }} | ||||
| 										</a>> | ||||
| 									</span> | ||||
| 								</span> | ||||
| 								<span v-else class="text-body-secondary">[Undisclosed recipients]</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.Cc && message.Cc.length" class="small"> | ||||
| 							<th>Cc</th> | ||||
| 							<td class="privacy"> | ||||
| 								<span v-for="(t, i) in message.Cc"> | ||||
| 									<template v-if="i > 0">,</template> | ||||
| 									<span class="text-spaces">{{ t.Name }}</span> | ||||
| 									<<a :href="searchURI(t.Address)" class="text-body"> | ||||
| 										{{ t.Address }} | ||||
| 									</a>> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.Bcc && message.Bcc.length" class="small"> | ||||
| 							<th>Bcc</th> | ||||
| 							<td class="privacy"> | ||||
| 								<span v-for="(t, i) in message.Bcc"> | ||||
| 									<template v-if="i > 0">,</template> | ||||
| 									<span class="text-spaces">{{ t.Name }}</span> | ||||
| 									<<a :href="searchURI(t.Address)" class="text-body"> | ||||
| 										{{ t.Address }} | ||||
| 									</a>> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small"> | ||||
| 							<th class="text-nowrap">Reply-To</th> | ||||
| 							<td class="privacy text-body-secondary text-break"> | ||||
| 								<span v-for="(t, i) in message.ReplyTo"> | ||||
| 									<template v-if="i > 0">,</template> | ||||
| 									<span class="text-spaces">{{ t.Name }}</span> | ||||
| 									<<a :href="searchURI(t.Address)" class="text-body-secondary"> | ||||
| 										{{ t.Address }} | ||||
| 									</a>> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address" | ||||
| 							class="small"> | ||||
| 							<th class="text-nowrap">Return-Path</th> | ||||
| 							<td class="privacy text-body-secondary text-break"> | ||||
| 								<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary"> | ||||
| 									{{ message.ReturnPath }} | ||||
| 								</a>> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr> | ||||
| 							<th class="small">Subject</th> | ||||
| 							<td> | ||||
| 								<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong> | ||||
| 								<small class="text-body-secondary" v-else>[ no subject ]</small> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr class="small"> | ||||
| 							<th class="small">Date</th> | ||||
| 							<td> | ||||
| 								{{ messageDate(message.Date) }} | ||||
| 								<small class="ms-2">({{ getFileSize(message.Size) }})</small> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.Username" class="small"> | ||||
| 							<th class="small"> | ||||
| 								Username | ||||
| 								<i class="bi bi-exclamation-circle ms-1" data-bs-toggle="tooltip" | ||||
| 									data-bs-placement="top" data-bs-custom-class="custom-tooltip" | ||||
| 									data-bs-title="The SMTP or send API username the client authenticated with"> | ||||
| 								</i> | ||||
| 							</th> | ||||
| 							<td class="small"> | ||||
| 								{{ message.Username }} | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr class="small"> | ||||
| 							<th>Tags</th> | ||||
| 							<td> | ||||
| 								<select class="form-select small tag-selector" v-model="messageTags" multiple | ||||
| 									data-full-width="false" data-suggestions-threshold="1" data-allow-new="true" | ||||
| 									data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..." | ||||
| 									data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$" | ||||
| 									data-separator="|,|"> | ||||
| 									<option value="">Type a tag...</option> | ||||
| 									<!-- you need at least one option with the placeholder --> | ||||
| 									<option v-for="t in availableTags" :value="t">{{ t }}</option> | ||||
| 								</select> | ||||
| 								<div class="invalid-feedback">Invalid tag name</div> | ||||
| 							</td> | ||||
| 						</tr> | ||||
|  | ||||
| 						<tr v-if="message.ListUnsubscribe.Header != ''" class="small" | ||||
| 							:class="showUnsubscribe ? '' : 'd-none'"> | ||||
| 							<th>Unsubscribe</th> | ||||
| 							<td> | ||||
| 								<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2"> | ||||
| 									<template v-for="(u, i) in message.ListUnsubscribe.Links"> | ||||
| 										<template v-if="i > 0">, </template> | ||||
| 										<{{ u }}> | ||||
| 									</template> | ||||
| 								</span> | ||||
| 								<i class="bi bi-info-circle text-success me-2 link" | ||||
| 									v-if="message.ListUnsubscribe.HeaderPost != ''" data-bs-toggle="tooltip" | ||||
| 									data-bs-placement="top" data-bs-custom-class="custom-tooltip" | ||||
| 									:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost"> | ||||
| 								</i> | ||||
| 								<i class="bi bi-exclamation-circle text-danger link" | ||||
| 									v-if="message.ListUnsubscribe.Errors != ''" data-bs-toggle="tooltip" | ||||
| 									data-bs-placement="top" data-bs-custom-class="custom-tooltip" | ||||
| 									:data-bs-title="message.ListUnsubscribe.Errors"> | ||||
| 								</i> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 					</tbody> | ||||
| 				</table> | ||||
| 			</div> | ||||
| 			<div class="col-md-auto d-none d-md-block text-end mt-md-3" | ||||
| 				v-if="message.Attachments && message.Attachments.length || message.Inline && message.Inline.length"> | ||||
| 				<div class="mt-2 mt-md-0"> | ||||
| 					<template v-if="message.Attachments.length"> | ||||
| 						<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message"> | ||||
| 							Attachment<span v-if="message.Attachments.length > 1">s</span> | ||||
| 							({{ message.Attachments.length }}) | ||||
| 						</span> | ||||
| 						<br> | ||||
| 					</template> | ||||
| 					<span class="badge rounded-pill text-bg-secondary p-2" v-if="message.Inline.length" | ||||
| 						title="Inline images in this message"> | ||||
| 						Inline image<span v-if="message.Inline.length > 1">s</span> | ||||
| 						({{ message.Inline.length }}) | ||||
| 					</span> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<nav class="nav nav-tabs my-3 d-print-none" id="nav-tab" role="tablist"> | ||||
| 			<template v-if="message.HTML"> | ||||
| 				<div class="btn-group"> | ||||
| 					<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html" | ||||
| 						type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml" | ||||
| 						v-on:click="resizeIFrames()"> | ||||
| 						HTML | ||||
| 					</button> | ||||
| 					<button type="button" class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none" | ||||
| 						data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent"> | ||||
| 						<span class="visually-hidden">Toggle Dropdown</span> | ||||
| 					</button> | ||||
| 					<div class="dropdown-menu"> | ||||
| 						<button class="dropdown-item" data-bs-toggle="tab" data-bs-target="#nav-html-source" | ||||
| 							type="button" role="tab" aria-controls="nav-html-source" aria-selected="false"> | ||||
| 							HTML Source | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<button class="nav-link d-none d-sm-inline" id="nav-html-source-tab" data-bs-toggle="tab" | ||||
| 					data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source" | ||||
| 					aria-selected="false"> | ||||
| 					HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span> | ||||
| 				</button> | ||||
| 			</template> | ||||
|  | ||||
| 			<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text" | ||||
| 				type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false" | ||||
| 				:class="message.HTML == '' ? 'show' : ''"> | ||||
| 				Text | ||||
| 			</button> | ||||
| 			<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers" | ||||
| 				type="button" role="tab" aria-controls="nav-headers" aria-selected="false"> | ||||
| 				<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span> | ||||
| 			</button> | ||||
| 			<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button" | ||||
| 				role="tab" aria-controls="nav-raw" aria-selected="false"> | ||||
| 				Raw | ||||
| 			</button> | ||||
| 			<div class="dropdown d-xl-none" v-show="hasAnyChecksEnabled"> | ||||
| 				<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
| 					Checks | ||||
| 				</button> | ||||
| 				<ul class="dropdown-menu checks"> | ||||
| 					<li v-if="mailbox.showHTMLCheck && message.HTML != ''"> | ||||
| 						<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab" | ||||
| 							data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html" | ||||
| 							aria-selected="false"> | ||||
| 							HTML Check | ||||
| 							<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor" | ||||
| 								v-if="htmlScore !== false"> | ||||
| 								<small>{{ Math.floor(htmlScore) }}%</small> | ||||
| 							</span> | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li v-if="mailbox.showLinkCheck"> | ||||
| 						<button class="dropdown-item" id="nav-link-check-tab" data-bs-toggle="tab" | ||||
| 							data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check" | ||||
| 							aria-selected="false"> | ||||
| 							Link Check | ||||
| 							<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0"> | ||||
| 								<small>0</small> | ||||
| 							</span> | ||||
| 							<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0"> | ||||
| 								<small>{{ formatNumber(linkCheckErrors) }}</small> | ||||
| 							</span> | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"> | ||||
| 						<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab" | ||||
| 							data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html" | ||||
| 							aria-selected="false"> | ||||
| 							Spam Analysis | ||||
| 							<span class="badge rounded-pill float-end" :class="spamScoreColor" | ||||
| 								v-if="spamScore !== false"> | ||||
| 								<small>{{ spamScore }}</small> | ||||
| 							</span> | ||||
| 						</button> | ||||
| 					</li> | ||||
| 				</ul> | ||||
| 			</div> | ||||
| 			<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab" | ||||
| 				data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html" | ||||
| 				aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''"> | ||||
| 				HTML Check | ||||
| 				<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false"> | ||||
| 					<small>{{ Math.floor(htmlScore) }}%</small> | ||||
| 				</span> | ||||
| 			</button> | ||||
| 			<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab" | ||||
| 				data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check" | ||||
| 				aria-selected="false" v-if="mailbox.showLinkCheck"> | ||||
| 				Link Check | ||||
| 				<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i> | ||||
| 				<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0"> | ||||
| 					<small>{{ formatNumber(linkCheckErrors) }}</small> | ||||
| 				</span> | ||||
| 			</button> | ||||
| 			<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab" | ||||
| 				data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html" | ||||
| 				aria-selected="false" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"> | ||||
| 				Spam Analysis | ||||
| 				<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false"> | ||||
| 					<small>{{ spamScore }}</small> | ||||
| 				</span> | ||||
| 			</button> | ||||
|  | ||||
| 			<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons"> | ||||
| 				<template v-for="_, key in responsiveSizes"> | ||||
| 					<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'" | ||||
| 						v-on:click="scaleHTMLPreview = key"> | ||||
| 						<i class="bi" :class="'bi-' + key"></i> | ||||
| 					</button> | ||||
| 				</template> | ||||
| 			</div> | ||||
| 		</nav> | ||||
|  | ||||
| 		<div class="tab-content mb-5" id="nav-tabContent"> | ||||
| 			<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel" | ||||
| 				aria-labelledby="nav-html-tab" tabindex="0"> | ||||
| 				<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]"> | ||||
| 					<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML" | ||||
| 						v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;"> | ||||
| 					</iframe> | ||||
| 				</div> | ||||
| 				<Attachments v-if="allAttachments(message).length" :message="message" | ||||
| 					:attachments="allAttachments(message)"> | ||||
| 				</Attachments> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab" | ||||
| 				tabindex="0" v-if="message.HTML"> | ||||
| 				<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" | ||||
| 				tabindex="0" :class="message.HTML == '' ? 'show' : ''"> | ||||
| 				<div class="text-view" v-html="textToHTML(message.Text)"></div> | ||||
| 				<Attachments v-if="allAttachments(message).length" :message="message" | ||||
| 					:attachments="allAttachments(message)"> | ||||
| 				</Attachments> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0"> | ||||
| 				<Headers v-if="loadHeaders" :message="message"></Headers> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0"> | ||||
| 				<iframe v-if="srcURI" :src="srcURI" v-on:load="initRawIframe" frameborder="0" | ||||
| 					style="width: 100%; height: 300px"></iframe> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab" | ||||
| 				tabindex="0"> | ||||
| 				<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''" :message="message" | ||||
| 					@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" /> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab" | ||||
| 				tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"> | ||||
| 				<SpamAssassin :message="message" @setSpamScore="(n) => spamScore = n" | ||||
| 					@set-badge-style="(v) => spamScoreColor = v" /> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab" | ||||
| 				tabindex="0" v-if="mailbox.showLinkCheck"> | ||||
| 				<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
| @@ -1,84 +1,102 @@ | ||||
| <script> | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import ICAL from "ical.js" | ||||
| import dayjs from 'dayjs' | ||||
| import commonMixins from "../../mixins/CommonMixins"; | ||||
| import ICAL from "ical.js"; | ||||
| import dayjs from "dayjs"; | ||||
| 
 | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 		attachments: Object | ||||
| 	}, | ||||
| 
 | ||||
| 	mixins: [commonMixins], | ||||
| 
 | ||||
| 	props: { | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 		attachments: { | ||||
| 			type: Object, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			ical: false | ||||
| 		} | ||||
| 			ical: false, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		openAttachment(part, e) { | ||||
| 			let filename = part.FileName | ||||
| 			let contentType = part.ContentType | ||||
| 			let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID) | ||||
| 			if (filename.match(/\.ics$/i) || contentType == 'text/calendar') { | ||||
| 				e.preventDefault() | ||||
| 			const filename = part.FileName; | ||||
| 			const contentType = part.ContentType; | ||||
| 			const href = this.resolve("/api/v1/message/" + this.message.ID + "/part/" + part.PartID); | ||||
| 			if (filename.match(/\.ics$/i) || contentType === "text/calendar") { | ||||
| 				e.preventDefault(); | ||||
| 
 | ||||
| 				this.get(href, null, (response) => { | ||||
| 					let comp = new ICAL.Component(ICAL.parse(response.data)) | ||||
| 					let vevent = comp.getFirstSubcomponent('vevent') | ||||
| 					const comp = new ICAL.Component(ICAL.parse(response.data)); | ||||
| 					const vevent = comp.getFirstSubcomponent("vevent"); | ||||
| 					if (!vevent) { | ||||
| 						alert('Error parsing ICS file') | ||||
| 						return | ||||
| 						alert("Error parsing ICS file"); | ||||
| 						return; | ||||
| 					} | ||||
| 					let event = new ICAL.Event(vevent) | ||||
| 					const event = new ICAL.Event(vevent); | ||||
| 
 | ||||
| 					let summary = {} | ||||
| 					summary.link = href | ||||
| 					summary.status = vevent.getFirstPropertyValue('status') | ||||
| 					summary.url = vevent.getFirstPropertyValue('url') | ||||
| 					summary.summary = event.summary | ||||
| 					summary.description = event.description | ||||
| 					summary.location = event.location | ||||
| 					summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a') | ||||
| 					summary.end = dayjs(event.endDate).format('ddd, D MMM YYYY, h:mm a') | ||||
| 					summary.isRecurring = event.isRecurring() | ||||
| 					summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false | ||||
| 					summary.attendees = [] | ||||
| 					const summary = {}; | ||||
| 					summary.link = href; | ||||
| 					summary.status = vevent.getFirstPropertyValue("status"); | ||||
| 					summary.url = vevent.getFirstPropertyValue("url"); | ||||
| 					summary.summary = event.summary; | ||||
| 					summary.description = event.description; | ||||
| 					summary.location = event.location; | ||||
| 					summary.start = dayjs(event.startDate).format("ddd, D MMM YYYY, h:mm a"); | ||||
| 					summary.end = dayjs(event.endDate).format("ddd, D MMM YYYY, h:mm a"); | ||||
| 					summary.isRecurring = event.isRecurring(); | ||||
| 					summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, "") : false; | ||||
| 					summary.attendees = []; | ||||
| 					event.attendees.forEach((a) => { | ||||
| 						if (a.jCal[1].cn) { | ||||
| 							summary.attendees.push(a.jCal[1].cn) | ||||
| 							summary.attendees.push(a.jCal[1].cn); | ||||
| 						} | ||||
| 					}) | ||||
| 					}); | ||||
| 
 | ||||
| 					comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => { | ||||
| 						summary.timezone = vtimezone.getFirstPropertyValue("tzid") | ||||
| 					}) | ||||
| 						summary.timezone = vtimezone.getFirstPropertyValue("tzid"); | ||||
| 					}); | ||||
| 
 | ||||
| 					this.ical = summary | ||||
| 					this.ical = summary; | ||||
| 
 | ||||
| 					// display modal | ||||
| 					this.modal('ICSView').show() | ||||
| 				}) | ||||
| 					this.modal("ICSView").show(); | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 		}, | ||||
| 	}, | ||||
| } | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
| 	<div class="mt-4 border-top pt-4"> | ||||
| 		<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)" | ||||
| 			class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px" | ||||
| 			@click="openAttachment(part, $event)"> | ||||
| 			<img v-if="isImage(part)" | ||||
| 				:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top" | ||||
| 				alt=""> | ||||
| 			<img v-else | ||||
| 		<a | ||||
| 			v-for="part in attachments" | ||||
| 			:key="part.PartID" | ||||
| 			:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)" | ||||
| 			class="card attachment float-start me-3 mb-3" | ||||
| 			target="_blank" | ||||
| 			style="width: 180px" | ||||
| 			@click="openAttachment(part, $event)" | ||||
| 		> | ||||
| 			<img | ||||
| 				v-if="isImage(part)" | ||||
| 				:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" | ||||
| 				class="card-img-top" | ||||
| 				alt="" | ||||
| 			/> | ||||
| 			<img | ||||
| 				v-else | ||||
| 				src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==" | ||||
| 				class="card-img-top" alt=""> | ||||
| 			<div class="icon" v-if="!isImage(part)"> | ||||
| 				class="card-img-top" | ||||
| 				alt="" | ||||
| 			/> | ||||
| 			<div v-if="!isImage(part)" class="icon"> | ||||
| 				<i class="bi" :class="attachmentIcon(part)"></i> | ||||
| 			</div> | ||||
| 			<div class="card-body border-0"> | ||||
| @@ -87,16 +105,16 @@ export default { | ||||
| 					<small>{{ getFileSize(part.Size) }}</small> | ||||
| 				</p> | ||||
| 				<p class="card-text mb-0 small"> | ||||
| 					{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }} | ||||
| 					{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }} | ||||
| 				</p> | ||||
| 			</div> | ||||
| 			<div class="card-footer small border-0 text-center text-truncate"> | ||||
| 				{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }} | ||||
| 				{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }} | ||||
| 			</div> | ||||
| 		</a> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<div class="modal fade" id="ICSView" tabindex="-1" aria-hidden="true"> | ||||
| 	<div id="ICSView" class="modal fade" tabindex="-1" aria-hidden="true"> | ||||
| 		<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| @@ -106,7 +124,7 @@ export default { | ||||
| 					</h5> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body" v-if="ical"> | ||||
| 				<div v-if="ical" class="modal-body"> | ||||
| 					<table class="table"> | ||||
| 						<tbody> | ||||
| 							<tr v-if="ical.summary"> | ||||
| @@ -126,7 +144,7 @@ export default { | ||||
| 							</tr> | ||||
| 							<tr v-if="ical.status"> | ||||
| 								<th>Status</th> | ||||
| 								<td> {{ ical.status }}</td> | ||||
| 								<td>{{ ical.status }}</td> | ||||
| 							</tr> | ||||
| 							<tr v-if="ical.location"> | ||||
| 								<th>Location</th> | ||||
| @@ -134,7 +152,9 @@ export default { | ||||
| 							</tr> | ||||
| 							<tr v-if="ical.url"> | ||||
| 								<th>URL</th> | ||||
| 								<td><a :href="ical.url" target="_blank">{{ ical.url }}</a></td> | ||||
| 								<td> | ||||
| 									<a :href="ical.url" target="_blank">{{ ical.url }}</a> | ||||
| 								</td> | ||||
| 							</tr> | ||||
| 							<tr v-if="ical.organizer"> | ||||
| 								<th>Organizer</th> | ||||
| @@ -143,7 +163,7 @@ export default { | ||||
| 							<tr v-if="ical.attendees.length"> | ||||
| 								<th>Attendees</th> | ||||
| 								<td> | ||||
| 									<span v-for="(a, i) in ical.attendees"> | ||||
| 									<span v-for="(a, i) in ical.attendees" :key="'attendee_' + i"> | ||||
| 										<template v-if="i > 0">,</template> | ||||
| 										{{ a }} | ||||
| 									</span> | ||||
| @@ -154,12 +174,9 @@ export default { | ||||
| 				</div> | ||||
| 				<div class="modal-footer"> | ||||
| 					<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 					<a class="btn btn-primary" target="_blank" :href="ical.link"> | ||||
| 						Download attachment | ||||
| 					</a> | ||||
| 					<a class="btn btn-primary" target="_blank" :href="ical.link"> Download attachment </a> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 
 | ||||
| </template> | ||||
							
								
								
									
										40
									
								
								server/ui-src/components/message/MessageHeaders.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								server/ui-src/components/message/MessageHeaders.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| <script> | ||||
| import commonMixins from "../../mixins/CommonMixins"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			headers: false, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		const uri = this.resolve("/api/v1/message/" + this.message.ID + "/headers"); | ||||
| 		this.get(uri, false, (response) => { | ||||
| 			this.headers = response.data; | ||||
| 		}); | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div v-if="headers" class="small"> | ||||
| 		<div v-for="(values, k) in headers" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100"> | ||||
| 			<div class="col-md-4 col-lg-3 col-xl-2 mb-2"> | ||||
| 				<b>{{ k }}</b> | ||||
| 			</div> | ||||
| 			<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary"> | ||||
| 				<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
							
								
								
									
										861
									
								
								server/ui-src/components/message/MessageItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										861
									
								
								server/ui-src/components/message/MessageItem.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,861 @@ | ||||
| <script> | ||||
| import Attachments from "./MessageAttachments.vue"; | ||||
| import Headers from "./MessageHeaders.vue"; | ||||
| import HTMLCheck from "./HTMLCheck.vue"; | ||||
| import LinkCheck from "./LinkCheck.vue"; | ||||
| import SpamAssassin from "./SpamAssassin.vue"; | ||||
| import Tags from "bootstrap5-tags"; | ||||
| import { Tooltip } from "bootstrap"; | ||||
| import commonMixins from "../../mixins/CommonMixins"; | ||||
| import { mailbox } from "../../stores/mailbox"; | ||||
| import DOMPurify from "dompurify"; | ||||
| import hljs from "highlight.js/lib/core"; | ||||
| import xml from "highlight.js/lib/languages/xml"; | ||||
|  | ||||
| hljs.registerLanguage("html", xml); | ||||
|  | ||||
| export default { | ||||
| 	components: { | ||||
| 		Attachments, | ||||
| 		Headers, | ||||
| 		HTMLCheck, | ||||
| 		LinkCheck, | ||||
| 		SpamAssassin, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["loadMessages"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			srcURI: false, | ||||
| 			iframes: [], // for resizing | ||||
| 			canSaveTags: false, // prevent auto-saving tags on render | ||||
| 			availableTags: [], | ||||
| 			messageTags: [], | ||||
| 			loadHeaders: false, | ||||
| 			htmlScore: false, | ||||
| 			htmlScoreColor: false, | ||||
| 			linkCheckErrors: false, | ||||
| 			spamScore: false, | ||||
| 			spamScoreColor: false, | ||||
| 			showMobileButtons: false, | ||||
| 			showUnsubscribe: false, | ||||
| 			scaleHTMLPreview: "display", | ||||
| 			// keys names match bootstrap icon names | ||||
| 			responsiveSizes: { | ||||
| 				phone: "width: 322px; height: 570px", | ||||
| 				tablet: "width: 768px; height: 1024px", | ||||
| 				display: "width: 100%; height: 100%", | ||||
| 			}, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		hasAnyChecksEnabled() { | ||||
| 			return ( | ||||
| 				(mailbox.showHTMLCheck && this.message.HTML) || | ||||
| 				mailbox.showLinkCheck || | ||||
| 				(mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin) | ||||
| 			); | ||||
| 		}, | ||||
|  | ||||
| 		// remove bad HTML, JavaScript, iframes etc | ||||
| 		sanitizedHTML() { | ||||
| 			// set target & rel on all links | ||||
| 			DOMPurify.addHook("afterSanitizeAttributes", (node) => { | ||||
| 				if ( | ||||
| 					node.tagName !== "A" || | ||||
| 					(node.hasAttribute("href") && node.getAttribute("href").substring(0, 1) === "#") | ||||
| 				) { | ||||
| 					return; | ||||
| 				} | ||||
| 				if ("target" in node) { | ||||
| 					node.setAttribute("target", "_blank"); | ||||
| 					node.setAttribute("rel", "noopener noreferrer"); | ||||
| 				} | ||||
| 				if (!node.hasAttribute("target") && (node.hasAttribute("xlink:href") || node.hasAttribute("href"))) { | ||||
| 					node.setAttribute("xlink:show", "_blank"); | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| 			const clean = DOMPurify.sanitize(this.message.HTML, { | ||||
| 				WHOLE_DOCUMENT: true, | ||||
| 				SANITIZE_DOM: false, | ||||
| 				ADD_TAGS: ["link", "meta", "o:p", "style"], | ||||
| 				ADD_ATTR: [ | ||||
| 					"bordercolor", | ||||
| 					"charset", | ||||
| 					"content", | ||||
| 					"hspace", | ||||
| 					"http-equiv", | ||||
| 					"itemprop", | ||||
| 					"itemscope", | ||||
| 					"itemtype", | ||||
| 					"link", | ||||
| 					"vertical-align", | ||||
| 					"vlink", | ||||
| 					"vspace", | ||||
| 					"xml:lang", | ||||
| 				], | ||||
| 				FORBID_ATTR: ["script"], | ||||
| 			}); | ||||
|  | ||||
| 			// for debugging | ||||
| 			// this.debugDOMPurify(DOMPurify.removed) | ||||
|  | ||||
| 			return clean; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		messageTags() { | ||||
| 			if (this.canSaveTags) { | ||||
| 				// save changes to tags | ||||
| 				this.saveTags(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		scaleHTMLPreview(v) { | ||||
| 			if (v === "display") { | ||||
| 				window.setTimeout(() => { | ||||
| 					this.resizeIFrames(); | ||||
| 				}, 500); | ||||
| 			} | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.canSaveTags = false; | ||||
| 		this.messageTags = this.message.Tags; | ||||
| 		this.renderUI(); | ||||
|  | ||||
| 		window.addEventListener("resize", this.resizeIFrames); | ||||
|  | ||||
| 		const headersTab = document.getElementById("nav-headers-tab"); | ||||
| 		headersTab.addEventListener("shown.bs.tab", (event) => { | ||||
| 			this.loadHeaders = true; | ||||
| 		}); | ||||
|  | ||||
| 		const rawTab = document.getElementById("nav-raw-tab"); | ||||
| 		rawTab.addEventListener("shown.bs.tab", (event) => { | ||||
| 			this.srcURI = this.resolve("/api/v1/message/" + this.message.ID + "/raw"); | ||||
| 			this.resizeIFrames(); | ||||
| 		}); | ||||
|  | ||||
| 		// manually refresh tags | ||||
| 		this.get(this.resolve(`/api/v1/tags`), false, (response) => { | ||||
| 			this.availableTags = response.data; | ||||
| 			this.$nextTick(() => { | ||||
| 				Tags.init("select[multiple]"); | ||||
| 				// delay tag change detection to allow Tags to load | ||||
| 				window.setTimeout(() => { | ||||
| 					this.canSaveTags = true; | ||||
| 				}, 200); | ||||
| 			}); | ||||
| 		}); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		isHTMLTabSelected() { | ||||
| 			this.showMobileButtons = this.$refs.navhtml && this.$refs.navhtml.classList.contains("active"); | ||||
| 		}, | ||||
|  | ||||
| 		renderUI() { | ||||
| 			// activate the first non-disabled tab | ||||
| 			document.querySelector("#nav-tab button:not([disabled])").click(); | ||||
| 			document.activeElement.blur(); // blur focus | ||||
| 			document.getElementById("message-view").scrollTop = 0; | ||||
|  | ||||
| 			this.isHTMLTabSelected(); | ||||
|  | ||||
| 			document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => { | ||||
| 				listObj.addEventListener("shown.bs.tab", (event) => { | ||||
| 					this.isHTMLTabSelected(); | ||||
| 				}); | ||||
| 			}); | ||||
|  | ||||
| 			const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); | ||||
| 			[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl)); | ||||
|  | ||||
| 			// delay 0.5s until vue has rendered the iframe content | ||||
| 			window.setTimeout(() => { | ||||
| 				const p = document.getElementById("preview-html"); | ||||
| 				if (p && typeof p.contentWindow.document.body === "object") { | ||||
| 					try { | ||||
| 						// make links open in new window | ||||
| 						const anchorEls = p.contentWindow.document.body.querySelectorAll("a"); | ||||
| 						for (let i = 0; i < anchorEls.length; i++) { | ||||
| 							const anchorEl = anchorEls[i]; | ||||
| 							const href = anchorEl.getAttribute("href"); | ||||
|  | ||||
| 							if (href && href.match(/^https?:\/\//i)) { | ||||
| 								anchorEl.setAttribute("target", "_blank"); | ||||
| 							} | ||||
| 						} | ||||
| 					} catch (error) {} | ||||
| 					this.resizeIFrames(); | ||||
| 				} | ||||
| 			}, 500); | ||||
|  | ||||
| 			// HTML highlighting | ||||
| 			hljs.highlightAll(); | ||||
| 		}, | ||||
|  | ||||
| 		resizeIframe(el) { | ||||
| 			const i = el.target; | ||||
| 			if (typeof i.contentWindow.document.body.scrollHeight === "number") { | ||||
| 				i.style.height = i.contentWindow.document.body.scrollHeight + 50 + "px"; | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		resizeIFrames() { | ||||
| 			if (this.scaleHTMLPreview !== "display") { | ||||
| 				return; | ||||
| 			} | ||||
| 			const h = document.getElementById("preview-html"); | ||||
| 			if (h) { | ||||
| 				if (typeof h.contentWindow.document.body.scrollHeight === "number") { | ||||
| 					h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px"; | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		// set the iframe body & text colors based on current theme | ||||
| 		initRawIframe(el) { | ||||
| 			const bodyStyles = window.getComputedStyle(document.body, null); | ||||
| 			const bg = bodyStyles.getPropertyValue("background-color"); | ||||
| 			const txt = bodyStyles.getPropertyValue("color"); | ||||
|  | ||||
| 			const body = el.target.contentWindow.document.querySelector("body"); | ||||
| 			if (body) { | ||||
| 				body.style.color = txt; | ||||
| 				body.style.backgroundColor = bg; | ||||
| 			} | ||||
|  | ||||
| 			this.resizeIframe(el); | ||||
| 		}, | ||||
|  | ||||
| 		// this function is unused but kept here to use for debugging | ||||
| 		debugDOMPurify(removed) { | ||||
| 			if (!removed.length) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const ignoreNodes = ["target", "base", "script", "v:shapes"]; | ||||
|  | ||||
| 			const d = removed.filter((r) => { | ||||
| 				if ( | ||||
| 					typeof r.attribute !== "undefined" && | ||||
| 					(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith("xmlns:")) | ||||
| 				) { | ||||
| 					return false; | ||||
| 				} | ||||
| 				// inline comments | ||||
| 				if (typeof r.element !== "undefined" && (r.element.nodeType === 8 || r.element.tagName === "SCRIPT")) { | ||||
| 					return false; | ||||
| 				} | ||||
|  | ||||
| 				return true; | ||||
| 			}); | ||||
|  | ||||
| 			if (d.length) { | ||||
| 				console.log(d); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		saveTags() { | ||||
| 			const data = { | ||||
| 				IDs: [this.message.ID], | ||||
| 				Tags: this.messageTags, | ||||
| 			}; | ||||
|  | ||||
| 			this.put(this.resolve("/api/v1/tags"), data, (response) => { | ||||
| 				window.scrollInPlace = true; | ||||
| 				this.$emit("loadMessages"); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		// Convert plain text to HTML including anchor links | ||||
| 		textToHTML(s) { | ||||
| 			let html = s; | ||||
|  | ||||
| 			// full links with http(s) | ||||
| 			const re = /(\b(https?|ftp):\/\/[-\w@:%_+'!.~#?,&//=;]+)/gim; | ||||
| 			html = html.replace(re, "˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲"); | ||||
|  | ||||
| 			// plain www links without https?:// prefix | ||||
| 			const re2 = /(^|[^/])(www\.[\S]+(\b|$))/gim; | ||||
| 			html = html.replace(re2, "$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲"); | ||||
|  | ||||
| 			// escape to HTML & convert <>" back | ||||
| 			html = html | ||||
| 				.replace(/&/g, "&") | ||||
| 				.replace(/</g, "<") | ||||
| 				.replace(/>/g, ">") | ||||
| 				.replace(/"/g, """) | ||||
| 				.replace(/'/g, "'") | ||||
| 				.replace(/˱˱˱/g, "<") | ||||
| 				.replace(/˲˲˲/g, ">") | ||||
| 				.replace(/ˠˠˠ/g, '"'); | ||||
|  | ||||
| 			return html; | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100"> | ||||
| 		<div class="row w-100"> | ||||
| 			<div class="col-md"> | ||||
| 				<table class="messageHeaders"> | ||||
| 					<tbody> | ||||
| 						<tr> | ||||
| 							<th class="small">From</th> | ||||
| 							<td class="privacy"> | ||||
| 								<span v-if="message.From"> | ||||
| 									<span v-if="message.From.Name" class="text-spaces"> | ||||
| 										{{ message.From.Name + " " }} | ||||
| 									</span> | ||||
| 									<span v-if="message.From.Address" class="small"> | ||||
| 										<<a :href="searchURI(message.From.Address)" class="text-body"> | ||||
| 											{{ message.From.Address }} </a | ||||
| 										>> | ||||
| 									</span> | ||||
| 								</span> | ||||
| 								<span v-else> [ Unknown ] </span> | ||||
|  | ||||
| 								<span | ||||
| 									v-if="message.ListUnsubscribe.Header != ''" | ||||
| 									class="small ms-3 link" | ||||
| 									:title=" | ||||
| 										showUnsubscribe | ||||
| 											? 'Hide unsubscribe information' | ||||
| 											: 'Show unsubscribe information' | ||||
| 									" | ||||
| 									@click="showUnsubscribe = !showUnsubscribe" | ||||
| 								> | ||||
| 									Unsubscribe | ||||
| 									<i | ||||
| 										class="bi bi bi-info-circle" | ||||
| 										:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }" | ||||
| 									></i> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr class="small"> | ||||
| 							<th>To</th> | ||||
| 							<td class="privacy"> | ||||
| 								<template v-if="message.To && message.To.length"> | ||||
| 									<span v-for="(t, i) in message.To" :key="'to_' + i"> | ||||
| 										<template v-if="i > 0">, </template> | ||||
| 										<span> | ||||
| 											<span class="text-spaces">{{ t.Name }}</span> | ||||
| 											<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a | ||||
| 											>> | ||||
| 										</span> | ||||
| 									</span> | ||||
| 								</template> | ||||
| 								<span v-else class="text-body-secondary">[Undisclosed recipients]</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.Cc && message.Cc.length" class="small"> | ||||
| 							<th>Cc</th> | ||||
| 							<td class="privacy"> | ||||
| 								<span v-for="(t, i) in message.Cc" :key="'cc_' + i"> | ||||
| 									<template v-if="i > 0">,</template> | ||||
| 									<span class="text-spaces">{{ t.Name }}</span> | ||||
| 									<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.Bcc && message.Bcc.length" class="small"> | ||||
| 							<th>Bcc</th> | ||||
| 							<td class="privacy"> | ||||
| 								<span v-for="(t, i) in message.Bcc" :key="'bcc_' + i"> | ||||
| 									<template v-if="i > 0">,</template> | ||||
| 									<span class="text-spaces">{{ t.Name }}</span> | ||||
| 									<<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small"> | ||||
| 							<th class="text-nowrap">Reply-To</th> | ||||
| 							<td class="privacy text-body-secondary text-break"> | ||||
| 								<span v-for="(t, i) in message.ReplyTo" :key="'bcc_' + i"> | ||||
| 									<template v-if="i > 0">,</template> | ||||
| 									<span class="text-spaces">{{ t.Name }}</span> | ||||
| 									<<a :href="searchURI(t.Address)" class="text-body-secondary"> {{ t.Address }} </a | ||||
| 									>> | ||||
| 								</span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr | ||||
| 							v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address" | ||||
| 							class="small" | ||||
| 						> | ||||
| 							<th class="text-nowrap">Return-Path</th> | ||||
| 							<td class="privacy text-body-secondary text-break"> | ||||
| 								<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary"> | ||||
| 									{{ message.ReturnPath }} </a | ||||
| 								>> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr> | ||||
| 							<th class="small">Subject</th> | ||||
| 							<td> | ||||
| 								<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong> | ||||
| 								<small v-else class="text-body-secondary">[ no subject ]</small> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr class="small"> | ||||
| 							<th class="small">Date</th> | ||||
| 							<td> | ||||
| 								{{ messageDate(message.Date) }} | ||||
| 								<small class="ms-2">({{ getFileSize(message.Size) }})</small> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.Username" class="small"> | ||||
| 							<th class="small"> | ||||
| 								Username | ||||
| 								<i | ||||
| 									class="bi bi-exclamation-circle ms-1" | ||||
| 									data-bs-toggle="tooltip" | ||||
| 									data-bs-placement="top" | ||||
| 									data-bs-custom-class="custom-tooltip" | ||||
| 									data-bs-title="The SMTP or send API username the client authenticated with" | ||||
| 								> | ||||
| 								</i> | ||||
| 							</th> | ||||
| 							<td class="small"> | ||||
| 								{{ message.Username }} | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr class="small"> | ||||
| 							<th>Tags</th> | ||||
| 							<td> | ||||
| 								<select | ||||
| 									v-model="messageTags" | ||||
| 									class="form-select small tag-selector" | ||||
| 									multiple | ||||
| 									data-full-width="false" | ||||
| 									data-suggestions-threshold="1" | ||||
| 									data-allow-new="true" | ||||
| 									data-clear-end="true" | ||||
| 									data-allow-clear="true" | ||||
| 									data-placeholder="Add tags..." | ||||
| 									data-badge-style="secondary" | ||||
| 									data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$" | ||||
| 									data-separator="|,|" | ||||
| 								> | ||||
| 									<option value="">Type a tag...</option> | ||||
| 									<!-- you need at least one option with the placeholder --> | ||||
| 									<option v-for="t in availableTags" :key="t" :value="t">{{ t }}</option> | ||||
| 								</select> | ||||
| 								<div class="invalid-feedback">Invalid tag name</div> | ||||
| 							</td> | ||||
| 						</tr> | ||||
|  | ||||
| 						<tr | ||||
| 							v-if="message.ListUnsubscribe.Header != ''" | ||||
| 							class="small" | ||||
| 							:class="showUnsubscribe ? '' : 'd-none'" | ||||
| 						> | ||||
| 							<th>Unsubscribe</th> | ||||
| 							<td> | ||||
| 								<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2"> | ||||
| 									<template v-for="(u, i) in message.ListUnsubscribe.Links"> | ||||
| 										<template v-if="i > 0">, </template> | ||||
| 										<{{ u }}> | ||||
| 									</template> | ||||
| 								</span> | ||||
| 								<i | ||||
| 									v-if="message.ListUnsubscribe.HeaderPost != ''" | ||||
| 									class="bi bi-info-circle text-success me-2 link" | ||||
| 									data-bs-toggle="tooltip" | ||||
| 									data-bs-placement="top" | ||||
| 									data-bs-custom-class="custom-tooltip" | ||||
| 									:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost" | ||||
| 								> | ||||
| 								</i> | ||||
| 								<i | ||||
| 									v-if="message.ListUnsubscribe.Errors != ''" | ||||
| 									class="bi bi-exclamation-circle text-danger link" | ||||
| 									data-bs-toggle="tooltip" | ||||
| 									data-bs-placement="top" | ||||
| 									data-bs-custom-class="custom-tooltip" | ||||
| 									:data-bs-title="message.ListUnsubscribe.Errors" | ||||
| 								> | ||||
| 								</i> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 					</tbody> | ||||
| 				</table> | ||||
| 			</div> | ||||
| 			<div | ||||
| 				v-if="(message.Attachments && message.Attachments.length) || (message.Inline && message.Inline.length)" | ||||
| 				class="col-md-auto d-none d-md-block text-end mt-md-3" | ||||
| 			> | ||||
| 				<div class="mt-2 mt-md-0"> | ||||
| 					<template v-if="message.Attachments.length"> | ||||
| 						<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message"> | ||||
| 							Attachment<span v-if="message.Attachments.length > 1">s</span> ({{ | ||||
| 								message.Attachments.length | ||||
| 							}}) | ||||
| 						</span> | ||||
| 						<br /> | ||||
| 					</template> | ||||
| 					<span | ||||
| 						v-if="message.Inline.length" | ||||
| 						class="badge rounded-pill text-bg-secondary p-2" | ||||
| 						title="Inline images in this message" | ||||
| 					> | ||||
| 						Inline image<span v-if="message.Inline.length > 1">s</span> ({{ message.Inline.length }}) | ||||
| 					</span> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<nav id="nav-tab" class="nav nav-tabs my-3 d-print-none" role="tablist"> | ||||
| 			<template v-if="message.HTML"> | ||||
| 				<div class="btn-group"> | ||||
| 					<button | ||||
| 						id="nav-html-tab" | ||||
| 						ref="navhtml" | ||||
| 						class="nav-link" | ||||
| 						data-bs-toggle="tab" | ||||
| 						data-bs-target="#nav-html" | ||||
| 						type="button" | ||||
| 						role="tab" | ||||
| 						aria-controls="nav-html" | ||||
| 						aria-selected="true" | ||||
| 						@click="resizeIFrames()" | ||||
| 					> | ||||
| 						HTML | ||||
| 					</button> | ||||
| 					<button | ||||
| 						type="button" | ||||
| 						class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none" | ||||
| 						data-bs-toggle="dropdown" | ||||
| 						aria-expanded="false" | ||||
| 						data-bs-reference="parent" | ||||
| 					> | ||||
| 						<span class="visually-hidden">Toggle Dropdown</span> | ||||
| 					</button> | ||||
| 					<div class="dropdown-menu"> | ||||
| 						<button | ||||
| 							class="dropdown-item" | ||||
| 							data-bs-toggle="tab" | ||||
| 							data-bs-target="#nav-html-source" | ||||
| 							type="button" | ||||
| 							role="tab" | ||||
| 							aria-controls="nav-html-source" | ||||
| 							aria-selected="false" | ||||
| 						> | ||||
| 							HTML Source | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<button | ||||
| 					id="nav-html-source-tab" | ||||
| 					class="nav-link d-none d-sm-inline" | ||||
| 					data-bs-toggle="tab" | ||||
| 					data-bs-target="#nav-html-source" | ||||
| 					type="button" | ||||
| 					role="tab" | ||||
| 					aria-controls="nav-html-source" | ||||
| 					aria-selected="false" | ||||
| 				> | ||||
| 					HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span> | ||||
| 				</button> | ||||
| 			</template> | ||||
|  | ||||
| 			<button | ||||
| 				id="nav-plain-text-tab" | ||||
| 				class="nav-link" | ||||
| 				data-bs-toggle="tab" | ||||
| 				data-bs-target="#nav-plain-text" | ||||
| 				type="button" | ||||
| 				role="tab" | ||||
| 				aria-controls="nav-plain-text" | ||||
| 				aria-selected="false" | ||||
| 				:class="message.HTML == '' ? 'show' : ''" | ||||
| 			> | ||||
| 				Text | ||||
| 			</button> | ||||
| 			<button | ||||
| 				id="nav-headers-tab" | ||||
| 				class="nav-link" | ||||
| 				data-bs-toggle="tab" | ||||
| 				data-bs-target="#nav-headers" | ||||
| 				type="button" | ||||
| 				role="tab" | ||||
| 				aria-controls="nav-headers" | ||||
| 				aria-selected="false" | ||||
| 			> | ||||
| 				<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span> | ||||
| 			</button> | ||||
| 			<button | ||||
| 				id="nav-raw-tab" | ||||
| 				class="nav-link" | ||||
| 				data-bs-toggle="tab" | ||||
| 				data-bs-target="#nav-raw" | ||||
| 				type="button" | ||||
| 				role="tab" | ||||
| 				aria-controls="nav-raw" | ||||
| 				aria-selected="false" | ||||
| 			> | ||||
| 				Raw | ||||
| 			</button> | ||||
| 			<div v-show="hasAnyChecksEnabled" class="dropdown d-xl-none"> | ||||
| 				<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
| 					Checks | ||||
| 				</button> | ||||
| 				<ul class="dropdown-menu checks"> | ||||
| 					<li v-if="mailbox.showHTMLCheck && message.HTML != ''"> | ||||
| 						<button | ||||
| 							id="nav-html-check-tab" | ||||
| 							class="dropdown-item" | ||||
| 							data-bs-toggle="tab" | ||||
| 							data-bs-target="#nav-html-check" | ||||
| 							type="button" | ||||
| 							role="tab" | ||||
| 							aria-controls="nav-html" | ||||
| 							aria-selected="false" | ||||
| 						> | ||||
| 							HTML Check | ||||
| 							<span | ||||
| 								v-if="htmlScore !== false" | ||||
| 								class="badge rounded-pill p-1 float-end" | ||||
| 								:class="htmlScoreColor" | ||||
| 							> | ||||
| 								<small>{{ Math.floor(htmlScore) }}%</small> | ||||
| 							</span> | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li v-if="mailbox.showLinkCheck"> | ||||
| 						<button | ||||
| 							id="nav-link-check-tab" | ||||
| 							class="dropdown-item" | ||||
| 							data-bs-toggle="tab" | ||||
| 							data-bs-target="#nav-link-check" | ||||
| 							type="button" | ||||
| 							role="tab" | ||||
| 							aria-controls="nav-link-check" | ||||
| 							aria-selected="false" | ||||
| 						> | ||||
| 							Link Check | ||||
| 							<span v-if="linkCheckErrors === 0" class="badge rounded-pill bg-success float-end"> | ||||
| 								<small>0</small> | ||||
| 							</span> | ||||
| 							<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger float-end"> | ||||
| 								<small>{{ formatNumber(linkCheckErrors) }}</small> | ||||
| 							</span> | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"> | ||||
| 						<button | ||||
| 							id="nav-spam-check-tab" | ||||
| 							class="dropdown-item" | ||||
| 							data-bs-toggle="tab" | ||||
| 							data-bs-target="#nav-spam-check" | ||||
| 							type="button" | ||||
| 							role="tab" | ||||
| 							aria-controls="nav-html" | ||||
| 							aria-selected="false" | ||||
| 						> | ||||
| 							Spam Analysis | ||||
| 							<span | ||||
| 								v-if="spamScore !== false" | ||||
| 								class="badge rounded-pill float-end" | ||||
| 								:class="spamScoreColor" | ||||
| 							> | ||||
| 								<small>{{ spamScore }}</small> | ||||
| 							</span> | ||||
| 						</button> | ||||
| 					</li> | ||||
| 				</ul> | ||||
| 			</div> | ||||
| 			<button | ||||
| 				v-if="mailbox.showHTMLCheck && message.HTML != ''" | ||||
| 				id="nav-html-check-tab" | ||||
| 				class="d-none d-xl-inline-block nav-link position-relative" | ||||
| 				data-bs-toggle="tab" | ||||
| 				data-bs-target="#nav-html-check" | ||||
| 				type="button" | ||||
| 				role="tab" | ||||
| 				aria-controls="nav-html" | ||||
| 				aria-selected="false" | ||||
| 			> | ||||
| 				HTML Check | ||||
| 				<span v-if="htmlScore !== false" class="badge rounded-pill p-1" :class="htmlScoreColor"> | ||||
| 					<small>{{ Math.floor(htmlScore) }}%</small> | ||||
| 				</span> | ||||
| 			</button> | ||||
| 			<button | ||||
| 				v-if="mailbox.showLinkCheck" | ||||
| 				id="nav-link-check-tab" | ||||
| 				class="d-none d-xl-inline-block nav-link" | ||||
| 				data-bs-toggle="tab" | ||||
| 				data-bs-target="#nav-link-check" | ||||
| 				type="button" | ||||
| 				role="tab" | ||||
| 				aria-controls="nav-link-check" | ||||
| 				aria-selected="false" | ||||
| 			> | ||||
| 				Link Check | ||||
| 				<i v-if="linkCheckErrors === 0" class="bi bi-check-all text-success"></i> | ||||
| 				<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger"> | ||||
| 					<small>{{ formatNumber(linkCheckErrors) }}</small> | ||||
| 				</span> | ||||
| 			</button> | ||||
| 			<button | ||||
| 				v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin" | ||||
| 				id="nav-spam-check-tab" | ||||
| 				class="d-none d-xl-inline-block nav-link position-relative" | ||||
| 				data-bs-toggle="tab" | ||||
| 				data-bs-target="#nav-spam-check" | ||||
| 				type="button" | ||||
| 				role="tab" | ||||
| 				aria-controls="nav-html" | ||||
| 				aria-selected="false" | ||||
| 			> | ||||
| 				Spam Analysis | ||||
| 				<span v-if="spamScore !== false" class="badge rounded-pill" :class="spamScoreColor"> | ||||
| 					<small>{{ spamScore }}</small> | ||||
| 				</span> | ||||
| 			</button> | ||||
|  | ||||
| 			<div v-if="showMobileButtons" class="d-none d-lg-block ms-auto me-3"> | ||||
| 				<template v-for="(_, key) in responsiveSizes" :key="'responsive_' + key"> | ||||
| 					<button | ||||
| 						class="btn" | ||||
| 						:disabled="scaleHTMLPreview == key" | ||||
| 						:title="'Switch to ' + key + ' view'" | ||||
| 						@click="scaleHTMLPreview = key" | ||||
| 					> | ||||
| 						<i class="bi" :class="'bi-' + key"></i> | ||||
| 					</button> | ||||
| 				</template> | ||||
| 			</div> | ||||
| 		</nav> | ||||
|  | ||||
| 		<div id="nav-tabContent" class="tab-content mb-5"> | ||||
| 			<div | ||||
| 				v-if="message.HTML != ''" | ||||
| 				id="nav-html" | ||||
| 				class="tab-pane fade show" | ||||
| 				role="tabpanel" | ||||
| 				aria-labelledby="nav-html-tab" | ||||
| 				tabindex="0" | ||||
| 			> | ||||
| 				<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]"> | ||||
| 					<iframe | ||||
| 						id="preview-html" | ||||
| 						target-blank="" | ||||
| 						class="tab-pane d-block" | ||||
| 						:srcdoc="sanitizedHTML" | ||||
| 						frameborder="0" | ||||
| 						style="width: 100%; height: 100%; background: #fff" | ||||
| 						@load="resizeIframe" | ||||
| 					> | ||||
| 					</iframe> | ||||
| 				</div> | ||||
| 				<Attachments | ||||
| 					v-if="allAttachments(message).length" | ||||
| 					:message="message" | ||||
| 					:attachments="allAttachments(message)" | ||||
| 				> | ||||
| 				</Attachments> | ||||
| 			</div> | ||||
| 			<div | ||||
| 				v-if="message.HTML" | ||||
| 				id="nav-html-source" | ||||
| 				class="tab-pane fade" | ||||
| 				role="tabpanel" | ||||
| 				aria-labelledby="nav-html-source-tab" | ||||
| 				tabindex="0" | ||||
| 			> | ||||
| 				<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre> | ||||
| 			</div> | ||||
| 			<div | ||||
| 				id="nav-plain-text" | ||||
| 				class="tab-pane fade" | ||||
| 				role="tabpanel" | ||||
| 				aria-labelledby="nav-plain-text-tab" | ||||
| 				tabindex="0" | ||||
| 				:class="message.HTML == '' ? 'show' : ''" | ||||
| 			> | ||||
| 				<!-- eslint-disable vue/no-v-html --> | ||||
| 				<div class="text-view" v-html="textToHTML(message.Text)"></div> | ||||
| 				<!-- -eslint-disable vue/no-v-html --> | ||||
| 				<Attachments | ||||
| 					v-if="allAttachments(message).length" | ||||
| 					:message="message" | ||||
| 					:attachments="allAttachments(message)" | ||||
| 				> | ||||
| 				</Attachments> | ||||
| 			</div> | ||||
| 			<div id="nav-headers" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0"> | ||||
| 				<Headers v-if="loadHeaders" :message="message"></Headers> | ||||
| 			</div> | ||||
| 			<div id="nav-raw" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0"> | ||||
| 				<iframe | ||||
| 					v-if="srcURI" | ||||
| 					:src="srcURI" | ||||
| 					frameborder="0" | ||||
| 					style="width: 100%; height: 300px" | ||||
| 					@load="initRawIframe" | ||||
| 				></iframe> | ||||
| 			</div> | ||||
| 			<div | ||||
| 				id="nav-html-check" | ||||
| 				class="tab-pane fade" | ||||
| 				role="tabpanel" | ||||
| 				aria-labelledby="nav-html-check-tab" | ||||
| 				tabindex="0" | ||||
| 			> | ||||
| 				<HTMLCheck | ||||
| 					v-if="mailbox.showHTMLCheck && message.HTML != ''" | ||||
| 					:message="message" | ||||
| 					@set-html-score="(n) => (htmlScore = n)" | ||||
| 					@set-badge-style="(v) => (htmlScoreColor = v)" | ||||
| 				/> | ||||
| 			</div> | ||||
| 			<div | ||||
| 				v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin" | ||||
| 				id="nav-spam-check" | ||||
| 				class="tab-pane fade" | ||||
| 				role="tabpanel" | ||||
| 				aria-labelledby="nav-spam-check-tab" | ||||
| 				tabindex="0" | ||||
| 			> | ||||
| 				<SpamAssassin | ||||
| 					:message="message" | ||||
| 					@set-spam-score="(n) => (spamScore = n)" | ||||
| 					@set-badge-style="(v) => (spamScoreColor = v)" | ||||
| 				/> | ||||
| 			</div> | ||||
| 			<div | ||||
| 				v-if="mailbox.showLinkCheck" | ||||
| 				id="nav-link-check" | ||||
| 				class="tab-pane fade" | ||||
| 				role="tabpanel" | ||||
| 				aria-labelledby="nav-html-check-tab" | ||||
| 				tabindex="0" | ||||
| 			> | ||||
| 				<LinkCheck :message="message" @set-link-errors="(n) => (linkCheckErrors = n)" /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
| @@ -1,19 +1,24 @@ | ||||
| <script> | ||||
| import AjaxLoader from '../AjaxLoader.vue' | ||||
| import Tags from "bootstrap5-tags" | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import { mailbox } from '../../stores/mailbox' | ||||
| import AjaxLoader from "../AjaxLoader.vue"; | ||||
| import Tags from "bootstrap5-tags"; | ||||
| import commonMixins from "../../mixins/CommonMixins"; | ||||
| import { mailbox } from "../../stores/mailbox"; | ||||
| 
 | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 	}, | ||||
| 
 | ||||
| 	components: { | ||||
| 		AjaxLoader, | ||||
| 	}, | ||||
| 
 | ||||
| 	emits: ['delete'], | ||||
| 	mixins: [commonMixins], | ||||
| 
 | ||||
| 	props: { | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			default: () => ({}), | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	emits: ["delete"], | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| @@ -21,64 +26,62 @@ export default { | ||||
| 			deleteAfterRelease: false, | ||||
| 			mailbox, | ||||
| 			allAddresses: [], | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	mixins: [commonMixins], | ||||
| 
 | ||||
| 	mounted() { | ||||
| 		let a = [] | ||||
| 		for (let i in this.message.To) { | ||||
| 			a.push(this.message.To[i].Address) | ||||
| 		const a = []; | ||||
| 		for (const i in this.message.To) { | ||||
| 			a.push(this.message.To[i].Address); | ||||
| 		} | ||||
| 		for (let i in this.message.Cc) { | ||||
| 			a.push(this.message.Cc[i].Address) | ||||
| 		for (const i in this.message.Cc) { | ||||
| 			a.push(this.message.Cc[i].Address); | ||||
| 		} | ||||
| 		for (let i in this.message.Bcc) { | ||||
| 			a.push(this.message.Bcc[i].Address) | ||||
| 		for (const i in this.message.Bcc) { | ||||
| 			a.push(this.message.Bcc[i].Address); | ||||
| 		} | ||||
| 
 | ||||
| 		// include only unique email addresses, regardless of casing | ||||
| 		this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map(ad => [ad.toLowerCase(), ad])).values()])) | ||||
| 		this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map((ad) => [ad.toLowerCase(), ad])).values()])); | ||||
| 
 | ||||
| 		this.addresses = this.allAddresses | ||||
| 		this.addresses = this.allAddresses; | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		// triggered manually after modal is shown | ||||
| 		initTags() { | ||||
| 			Tags.init("select[multiple]") | ||||
| 			Tags.init("select[multiple]"); | ||||
| 		}, | ||||
| 
 | ||||
| 		releaseMessage() { | ||||
| 			// set timeout to allow for user clicking send before the tag filter has applied the tag | ||||
| 			window.setTimeout(() => { | ||||
| 				if (!this.addresses.length) { | ||||
| 					return false | ||||
| 					return false; | ||||
| 				} | ||||
| 
 | ||||
| 				let data = { | ||||
| 					To: this.addresses | ||||
| 				} | ||||
| 				const data = { | ||||
| 					To: this.addresses, | ||||
| 				}; | ||||
| 
 | ||||
| 				this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => { | ||||
| 					this.modal("ReleaseModal").hide() | ||||
| 				this.post(this.resolve("/api/v1/message/" + this.message.ID + "/release"), data, (response) => { | ||||
| 					this.modal("ReleaseModal").hide(); | ||||
| 					if (this.deleteAfterRelease) { | ||||
| 						this.$emit('delete') | ||||
| 						this.$emit("delete"); | ||||
| 					} | ||||
| 				}) | ||||
| 			}, 100) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 				}); | ||||
| 			}, 100); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
| 	<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true"> | ||||
| 		<div class="modal-dialog modal-xl" v-if="message"> | ||||
| 	<div id="ReleaseModal" class="modal fade" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true"> | ||||
| 		<div v-if="message" class="modal-dialog modal-xl"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1> | ||||
| 					<h1 id="AppInfoModalLabel" class="modal-title fs-5">Release email</h1> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| @@ -86,32 +89,55 @@ export default { | ||||
| 					<div class="row"> | ||||
| 						<label class="col-sm-2 col-form-label text-body-secondary">From</label> | ||||
| 						<div class="col-sm-10"> | ||||
| 							<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text" | ||||
| 								aria-label="From address" readonly class="form-control-plaintext" | ||||
| 								:value="mailbox.uiConfig.MessageRelay.OverrideFrom"> | ||||
| 							<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext" | ||||
| 								:value="message.From ? message.From.Address : ''"> | ||||
| 							<input | ||||
| 								v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" | ||||
| 								type="text" | ||||
| 								aria-label="From address" | ||||
| 								readonly | ||||
| 								class="form-control-plaintext" | ||||
| 								:value="mailbox.uiConfig.MessageRelay.OverrideFrom" | ||||
| 							/> | ||||
| 							<input | ||||
| 								v-else | ||||
| 								type="text" | ||||
| 								aria-label="From address" | ||||
| 								readonly | ||||
| 								class="form-control-plaintext" | ||||
| 								:value="message.From ? message.From.Address : ''" | ||||
| 							/> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="row"> | ||||
| 						<label class=" col-sm-2 col-form-label text-body-secondary">Subject</label> | ||||
| 						<label class="col-sm-2 col-form-label text-body-secondary">Subject</label> | ||||
| 						<div class="col-sm-10"> | ||||
| 							<input type="text" aria-label="Subject" readonly class="form-control-plaintext" | ||||
| 								:value="message.Subject"> | ||||
| 							<input | ||||
| 								type="text" | ||||
| 								aria-label="Subject" | ||||
| 								readonly | ||||
| 								class="form-control-plaintext" | ||||
| 								:value="message.Subject" | ||||
| 							/> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="row mb-3"> | ||||
| 						<label class="col-sm-2 col-form-label text-body-secondary">Send to</label> | ||||
| 						<div class="col-sm-10"> | ||||
| 							<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true" | ||||
| 								data-clear-end="true" data-allow-clear="true" | ||||
| 								data-placeholder="Enter email addresses..." data-add-on-blur="true" | ||||
| 							<select | ||||
| 								v-model="addresses" | ||||
| 								class="form-select tag-selector" | ||||
| 								multiple | ||||
| 								data-allow-new="true" | ||||
| 								data-clear-end="true" | ||||
| 								data-allow-clear="true" | ||||
| 								data-placeholder="Enter email addresses..." | ||||
| 								data-add-on-blur="true" | ||||
| 								data-badge-style="primary" | ||||
| 								data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$' | ||||
| 								data-separator="|,|"> | ||||
| 								data-separator="|,|" | ||||
| 							> | ||||
| 								<option value="">Enter email addresses...</option> | ||||
| 								<!-- you need at least one option with the placeholder --> | ||||
| 								<option v-for="t in allAddresses" :value="t">{{ t }}</option> | ||||
| 								<option v-for="t in allAddresses" :key="'address+' + t" :value="t">{{ t }}</option> | ||||
| 							</select> | ||||
| 							<div class="invalid-feedback">Invalid email address</div> | ||||
| 						</div> | ||||
| @@ -119,8 +145,12 @@ export default { | ||||
| 					<div class="row mb-3"> | ||||
| 						<div class="col-sm-10 offset-sm-2"> | ||||
| 							<div class="form-check"> | ||||
| 								<input class="form-check-input" type="checkbox" v-model="deleteAfterRelease" | ||||
| 									id="DeleteAfterRelease"> | ||||
| 								<input | ||||
| 									id="DeleteAfterRelease" | ||||
| 									v-model="deleteAfterRelease" | ||||
| 									class="form-check-input" | ||||
| 									type="checkbox" | ||||
| 								/> | ||||
| 								<label class="form-check-label" for="DeleteAfterRelease"> | ||||
| 									Delete the message after release | ||||
| 								</label> | ||||
| @@ -145,7 +175,8 @@ export default { | ||||
| 						</li> | ||||
| 						<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text"> | ||||
| 							The <code>From</code> email address has been overridden by the relay configuration to | ||||
| 							<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code>. | ||||
| 							<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code | ||||
| 							>. | ||||
| 						</li> | ||||
| 						<li class="form-text"> | ||||
| 							SMTP delivery failures will bounce back to | ||||
| @@ -155,14 +186,16 @@ export default { | ||||
| 							<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''"> | ||||
| 								{{ mailbox.uiConfig.MessageRelay.OverrideFrom }} | ||||
| 							</code> | ||||
| 							<code v-else>{{ message.ReturnPath }}</code>. | ||||
| 							<code v-else>{{ message.ReturnPath }}</code | ||||
| 							>. | ||||
| 						</li> | ||||
| 					</ul> | ||||
| 				</div> | ||||
| 				<div class="modal-footer"> | ||||
| 					<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | ||||
| 					<button type="button" class="btn btn-primary" :disabled="!addresses.length" | ||||
| 						v-on:click="releaseMessage">Release</button> | ||||
| 					<button type="button" class="btn btn-primary" :disabled="!addresses.length" @click="releaseMessage"> | ||||
| 						Release | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
							
								
								
									
										165
									
								
								server/ui-src/components/message/MessageScreenshot.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								server/ui-src/components/message/MessageScreenshot.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | ||||
| <script> | ||||
| import AjaxLoader from "../AjaxLoader.vue"; | ||||
| import CommonMixins from "../../mixins/CommonMixins"; | ||||
| import { domToPng } from "modern-screenshot"; | ||||
|  | ||||
| export default { | ||||
| 	components: { | ||||
| 		AjaxLoader, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			default: () => ({}), | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			html: false, | ||||
| 			loading: 0, | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		initScreenshot() { | ||||
| 			this.loading = 1; | ||||
| 			// remove base tag, if set | ||||
| 			let h = this.message.HTML.replace(/<base .*>/im, ""); | ||||
| 			const proxy = this.resolve("/proxy"); | ||||
|  | ||||
| 			// Outlook hacks - else screenshot returns blank image | ||||
| 			h = h.replace(/<html [^>]+>/gim, "<html>"); // remove html attributes | ||||
| 			h = h.replace(/<o:p><\/o:p>/gm, ""); // remove empty `<o:p></o:p>` tags | ||||
| 			h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>` | ||||
| 			h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>` | ||||
|  | ||||
| 			// update any inline `url(...)` absolute links | ||||
| 			const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim; | ||||
| 			h = h.replaceAll(urlRegex, (match, p1, p2, p3) => { | ||||
| 				if (typeof p2 === "string") { | ||||
| 					return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`; | ||||
| 				} | ||||
| 				return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`; | ||||
| 			}); | ||||
|  | ||||
| 			// create temporary document to manipulate | ||||
| 			const doc = document.implementation.createHTMLDocument(); | ||||
| 			doc.open(); | ||||
| 			doc.write(h); | ||||
| 			doc.close(); | ||||
|  | ||||
| 			// remove any <script> tags | ||||
| 			const scripts = doc.getElementsByTagName("script"); | ||||
| 			for (const i of scripts) { | ||||
| 				i.parentNode.removeChild(i); | ||||
| 			} | ||||
|  | ||||
| 			// replace stylesheet links with proxy links | ||||
| 			const stylesheets = doc.getElementsByTagName("link"); | ||||
| 			for (const i of stylesheets) { | ||||
| 				const src = i.getAttribute("href"); | ||||
|  | ||||
| 				if ( | ||||
| 					src && | ||||
| 					src.match(/^https?:\/\//i) && | ||||
| 					src.indexOf(window.location.origin + window.location.pathname) !== 0 | ||||
| 				) { | ||||
| 					i.setAttribute("href", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// replace images with proxy links | ||||
| 			const images = doc.getElementsByTagName("img"); | ||||
| 			for (const i of images) { | ||||
| 				const src = i.getAttribute("src"); | ||||
| 				if ( | ||||
| 					src && | ||||
| 					src.match(/^https?:\/\//i) && | ||||
| 					src.indexOf(window.location.origin + window.location.pathname) !== 0 | ||||
| 				) { | ||||
| 					i.setAttribute("src", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// replace background="" attributes with proxy links | ||||
| 			const backgrounds = doc.querySelectorAll("[background]"); | ||||
| 			for (const i of backgrounds) { | ||||
| 				const src = i.getAttribute("background"); | ||||
|  | ||||
| 				if ( | ||||
| 					src && | ||||
| 					src.match(/^https?:\/\//i) && | ||||
| 					src.indexOf(window.location.origin + window.location.pathname) !== 0 | ||||
| 				) { | ||||
| 					// replace with proxy link | ||||
| 					i.setAttribute("background", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// set html with manipulated document content | ||||
| 			this.html = new XMLSerializer().serializeToString(doc); | ||||
| 		}, | ||||
|  | ||||
| 		// HTML decode function | ||||
| 		decodeEntities(s) { | ||||
| 			const e = document.createElement("div"); | ||||
| 			e.innerHTML = s; | ||||
| 			const str = e.textContent; | ||||
| 			e.textContent = ""; | ||||
| 			return str; | ||||
| 		}, | ||||
|  | ||||
| 		doScreenshot() { | ||||
| 			let width = document.getElementById("message-view").getBoundingClientRect().width; | ||||
|  | ||||
| 			const prev = document.getElementById("preview-html"); | ||||
| 			if (prev && prev.getBoundingClientRect().width) { | ||||
| 				width = prev.getBoundingClientRect().width; | ||||
| 			} | ||||
|  | ||||
| 			if (width < 300) { | ||||
| 				width = 300; | ||||
| 			} | ||||
|  | ||||
| 			const i = document.getElementById("screenshot-html"); | ||||
|  | ||||
| 			// set the iframe width | ||||
| 			i.style.width = width + "px"; | ||||
|  | ||||
| 			const body = i.contentWindow.document.querySelector("body"); | ||||
|  | ||||
| 			// take screenshot of iframe | ||||
| 			domToPng(body, { | ||||
| 				backgroundColor: "#ffffff", | ||||
| 				height: i.contentWindow.document.body.scrollHeight + 20, | ||||
| 				width, | ||||
| 			}).then((dataUrl) => { | ||||
| 				const link = document.createElement("a"); | ||||
| 				link.download = this.message.ID + ".png"; | ||||
| 				link.href = dataUrl; | ||||
| 				link.click(); | ||||
| 				this.loading = 0; | ||||
| 				this.html = false; | ||||
| 			}); | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<iframe | ||||
| 		v-if="html" | ||||
| 		id="screenshot-html" | ||||
| 		:srcdoc="html" | ||||
| 		frameborder="0" | ||||
| 		style="position: absolute; margin-left: -100000px" | ||||
| 		@load="doScreenshot" | ||||
| 	> | ||||
| 	</iframe> | ||||
|  | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
| @@ -1,144 +0,0 @@ | ||||
| <script> | ||||
| import AjaxLoader from '../AjaxLoader.vue' | ||||
| import CommonMixins from '../../mixins/CommonMixins' | ||||
| import { domToPng } from 'modern-screenshot' | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         message: Object, | ||||
|     }, | ||||
|  | ||||
|     mixins: [CommonMixins], | ||||
|  | ||||
|     components: { | ||||
|         AjaxLoader, | ||||
|     }, | ||||
|  | ||||
|     data() { | ||||
|         return { | ||||
|             html: false, | ||||
|             loading: 0 | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     methods: { | ||||
|         initScreenshot() { | ||||
|             this.loading = 1 | ||||
|             // remove base tag, if set | ||||
|             let h = this.message.HTML.replace(/<base .*>/mi, '') | ||||
|             let proxy = this.resolve('/proxy') | ||||
|  | ||||
|             // Outlook hacks - else screenshot returns blank image | ||||
|             h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes | ||||
|             h = h.replace(/<o:p><\/o:p>/mg, '') // remove empty `<o:p></o:p>` tags | ||||
|             h = h.replace(/<o:/mg, '<') // replace `<o:p>` tags with `<p>`  | ||||
|             h = h.replace(/<\/o:/mg, '</') // replace `</o:p>` tags with `</p>`  | ||||
|  | ||||
|             // update any inline `url(...)` absolute links | ||||
|             const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi; | ||||
|             h = h.replaceAll(urlRegex, (match, p1, p2, p3) => { | ||||
|                 if (typeof p2 === 'string') { | ||||
|                     return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})` | ||||
|                 } | ||||
|                 return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)` | ||||
|             }) | ||||
|  | ||||
|             // create temporary document to manipulate | ||||
|             let doc = document.implementation.createHTMLDocument(); | ||||
|             doc.open() | ||||
|             doc.write(h) | ||||
|             doc.close() | ||||
|  | ||||
|             // remove any <script> tags | ||||
|             let scripts = doc.getElementsByTagName('script') | ||||
|             for (let i of scripts) { | ||||
|                 i.parentNode.removeChild(i) | ||||
|             } | ||||
|  | ||||
|             // replace stylesheet links with proxy links | ||||
|             let stylesheets = doc.getElementsByTagName('link') | ||||
|             for (let i of stylesheets) { | ||||
|                 let src = i.getAttribute('href') | ||||
|  | ||||
|                 if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) { | ||||
|                     i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // replace images with proxy links | ||||
|             let images = doc.getElementsByTagName('img') | ||||
|             for (let i of images) { | ||||
|                 let src = i.getAttribute('src') | ||||
|                 if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) { | ||||
|                     i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // replace background="" attributes with proxy links | ||||
|             let backgrounds = doc.querySelectorAll("[background]") | ||||
|             for (let i of backgrounds) { | ||||
|                 let src = i.getAttribute('background') | ||||
|  | ||||
|                 if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) { | ||||
|                     // replace with proxy link | ||||
|                     i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // set html with manipulated document content | ||||
|             this.html = new XMLSerializer().serializeToString(doc) | ||||
|         }, | ||||
|  | ||||
|         // HTML decode function | ||||
|         decodeEntities(s) { | ||||
|             let e = document.createElement('div') | ||||
|             e.innerHTML = s | ||||
|             let str = e.textContent | ||||
|             e.textContent = '' | ||||
|             return str | ||||
|         }, | ||||
|  | ||||
|         doScreenshot() { | ||||
|             let width = document.getElementById('message-view').getBoundingClientRect().width | ||||
|  | ||||
|             let prev = document.getElementById('preview-html') | ||||
|             if (prev && prev.getBoundingClientRect().width) { | ||||
|                 width = prev.getBoundingClientRect().width | ||||
|             } | ||||
|  | ||||
|             if (width < 300) { | ||||
|                 width = 300 | ||||
|             } | ||||
|  | ||||
|             const i = document.getElementById('screenshot-html') | ||||
|  | ||||
|             // set the iframe width | ||||
|             i.style.width = width + 'px' | ||||
|  | ||||
|             let body = i.contentWindow.document.querySelector('body') | ||||
|  | ||||
|             // take screenshot of iframe | ||||
|             domToPng(body, { | ||||
|                 backgroundColor: '#ffffff', | ||||
|                 height: i.contentWindow.document.body.scrollHeight + 20, | ||||
|                 width: width, | ||||
|             }).then(dataUrl => { | ||||
|                 const link = document.createElement('a') | ||||
|                 link.download = this.message.ID + '.png' | ||||
|                 link.href = dataUrl | ||||
|                 link.click() | ||||
|                 this.loading = 0 | ||||
|                 this.html = false | ||||
|             }) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <iframe v-if="html" :srcdoc="html" v-on:load="doScreenshot" frameborder="0" id="screenshot-html" | ||||
|         style="position: absolute; margin-left: -100000px;"> | ||||
|     </iframe> | ||||
|  | ||||
|     <AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
| @@ -1,52 +1,85 @@ | ||||
| <script> | ||||
| import { VcDonut } from 'vue-css-donut-chart' | ||||
| import axios from 'axios' | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import { VcDonut } from "vue-css-donut-chart"; | ||||
| import axios from "axios"; | ||||
| import commonMixins from "../../mixins/CommonMixins"; | ||||
|  | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 	}, | ||||
|  | ||||
| 	components: { | ||||
| 		VcDonut, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["setSpamScore", "setBadgeStyle"], | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	props: { | ||||
| 		message: { | ||||
| 			type: Object, | ||||
| 			default: () => ({}), | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["setSpamScore", "setBadgeStyle"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			error: false, | ||||
| 			check: false, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.doCheck() | ||||
| 	computed: { | ||||
| 		graphSections() { | ||||
| 			const score = this.check.Score; | ||||
| 			let p = Math.round((score / 5) * 100); | ||||
| 			if (p > 100) { | ||||
| 				p = 100; | ||||
| 			} else if (p < 0) { | ||||
| 				p = 0; | ||||
| 			} | ||||
|  | ||||
| 			let c = "#ffc107"; | ||||
| 			if (this.check.IsSpam) { | ||||
| 				c = "#dc3545"; | ||||
| 			} | ||||
|  | ||||
| 			return [ | ||||
| 				{ | ||||
| 					label: score + " / 5", | ||||
| 					value: p, | ||||
| 					color: c, | ||||
| 				}, | ||||
| 			]; | ||||
| 		}, | ||||
|  | ||||
| 		scoreColor() { | ||||
| 			return this.graphSections[0].color; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		message: { | ||||
| 			handler() { | ||||
| 				this.$emit('setSpamScore', false) | ||||
| 				this.doCheck() | ||||
| 				this.$emit("setSpamScore", false); | ||||
| 				this.doCheck(); | ||||
| 			}, | ||||
| 			deep: true | ||||
| 			deep: true, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.doCheck(); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		doCheck() { | ||||
| 			this.check = false | ||||
| 			this.check = false; | ||||
|  | ||||
| 			// ignore any error, do not show loader | ||||
| 			axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null) | ||||
| 			axios | ||||
| 				.get(this.resolve("/api/v1/message/" + this.message.ID + "/sa-check"), null) | ||||
| 				.then((result) => { | ||||
| 					this.check = result.data | ||||
| 					this.error = false | ||||
| 					this.setIcons() | ||||
| 					this.check = result.data; | ||||
| 					this.error = false; | ||||
| 					this.setIcons(); | ||||
| 				}) | ||||
| 				.catch((error) => { | ||||
| 					// handle error | ||||
| @@ -54,80 +87,50 @@ export default { | ||||
| 						// The request was made and the server responded with a status code | ||||
| 						// that falls out of the range of 2xx | ||||
| 						if (error.response.data.Error) { | ||||
| 							this.error = error.response.data.Error | ||||
| 							this.error = error.response.data.Error; | ||||
| 						} else { | ||||
| 							this.error = error.response.data | ||||
| 							this.error = error.response.data; | ||||
| 						} | ||||
| 					} else if (error.request) { | ||||
| 						// The request was made but no response was received | ||||
| 						// `error.request` is an instance of XMLHttpRequest in the browser and an instance of | ||||
| 						// http.ClientRequest in node.js | ||||
| 						this.error = 'Error sending data to the server. Please try again.' | ||||
| 						this.error = "Error sending data to the server. Please try again."; | ||||
| 					} else { | ||||
| 						// Something happened in setting up the request that triggered an Error | ||||
| 						this.error = error.message | ||||
| 						this.error = error.message; | ||||
| 					} | ||||
| 				}) | ||||
| 				}); | ||||
| 		}, | ||||
|  | ||||
| 		badgeStyle(ignorePadding = false) { | ||||
| 			let badgeStyle = 'bg-success' | ||||
| 			let badgeStyle = "bg-success"; | ||||
| 			if (this.check.Error) { | ||||
| 				badgeStyle = 'bg-warning text-primary' | ||||
| 			} | ||||
| 			else if (this.check.IsSpam) { | ||||
| 				badgeStyle = 'bg-danger' | ||||
| 				badgeStyle = "bg-warning text-primary"; | ||||
| 			} else if (this.check.IsSpam) { | ||||
| 				badgeStyle = "bg-danger"; | ||||
| 			} else if (this.check.Score >= 4) { | ||||
| 				badgeStyle = 'bg-warning text-primary' | ||||
| 				badgeStyle = "bg-warning text-primary"; | ||||
| 			} | ||||
|  | ||||
| 			if (!ignorePadding && String(this.check.Score).includes('.')) { | ||||
| 				badgeStyle += " p-1" | ||||
| 			if (!ignorePadding && String(this.check.Score).includes(".")) { | ||||
| 				badgeStyle += " p-1"; | ||||
| 			} | ||||
|  | ||||
| 			return badgeStyle | ||||
| 			return badgeStyle; | ||||
| 		}, | ||||
|  | ||||
| 		setIcons() { | ||||
| 			let score = this.check.Score | ||||
| 			if (this.check.Error && this.check.Error != '') { | ||||
| 				score = '!' | ||||
| 			let score = this.check.Score; | ||||
| 			if (this.check.Error && this.check.Error !== "") { | ||||
| 				score = "!"; | ||||
| 			} | ||||
| 			let badgeStyle = this.badgeStyle() | ||||
| 			this.$emit('setBadgeStyle', badgeStyle) | ||||
| 			this.$emit('setSpamScore', score) | ||||
| 			const badgeStyle = this.badgeStyle(); | ||||
| 			this.$emit("setBadgeStyle", badgeStyle); | ||||
| 			this.$emit("setSpamScore", score); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		graphSections() { | ||||
| 			let score = this.check.Score | ||||
| 			let p = Math.round(score / 5 * 100) | ||||
| 			if (p > 100) { | ||||
| 				p = 100 | ||||
| 			} else if (p < 0) { | ||||
| 				p = 0 | ||||
| 			} | ||||
|  | ||||
| 			let c = '#ffc107' | ||||
| 			if (this.check.IsSpam) { | ||||
| 				c = '#dc3545' | ||||
| 			} | ||||
|  | ||||
| 			return [ | ||||
| 				{ | ||||
| 					label: score + ' / 5', | ||||
| 					value: p, | ||||
| 					color: c | ||||
| 				}, | ||||
| 			] | ||||
| 		}, | ||||
|  | ||||
| 		scoreColor() { | ||||
| 			return this.graphSections[0].color | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -145,10 +148,10 @@ export default { | ||||
|  | ||||
| 	<template v-if="error || check.Error != ''"> | ||||
| 		<p>Your message could not be checked</p> | ||||
| 		<div class="alert alert-warning" v-if="error"> | ||||
| 		<div v-if="error" class="alert alert-warning"> | ||||
| 			{{ error }} | ||||
| 		</div> | ||||
| 		<div class="alert alert-warning" v-else> | ||||
| 		<div v-else class="alert alert-warning"> | ||||
| 			There was an error contacting the configured SpamAssassin server: {{ check.Error }} | ||||
| 		</div> | ||||
| 	</template> | ||||
| @@ -156,11 +159,18 @@ export default { | ||||
| 	<template v-else-if="check"> | ||||
| 		<div class="row w-100 mt-5"> | ||||
| 			<div class="col-xl-5 mb-2"> | ||||
| 				<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20" | ||||
| 					:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754"> | ||||
| 					<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings"> | ||||
| 						{{ check.Score }} / 5 | ||||
| 					</h2> | ||||
| 				<vc-donut | ||||
| 					:sections="graphSections" | ||||
| 					background="var(--bs-body-bg)" | ||||
| 					:size="230" | ||||
| 					unit="px" | ||||
| 					:thickness="20" | ||||
| 					:total="100" | ||||
| 					:start-angle="270" | ||||
| 					:auto-adjust-text-size="true" | ||||
| 					foreground="#198754" | ||||
| 				> | ||||
| 					<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">{{ check.Score }} / 5</h2> | ||||
| 					<div class="text-body mt-2"> | ||||
| 						<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span> | ||||
| 						<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span> | ||||
| @@ -180,7 +190,7 @@ export default { | ||||
| 					</div> | ||||
| 				</div> | ||||
|  | ||||
| 				<div class="row w-100 py-2 border-bottom small" v-for="r in check.Rules"> | ||||
| 				<div v-for="r in check.Rules" :key="'rule_' + r.Name" class="row w-100 py-2 border-bottom small"> | ||||
| 					<div class="col-2 col-lg-1"> | ||||
| 						{{ r.Score }} | ||||
| 					</div> | ||||
| @@ -195,25 +205,39 @@ export default { | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel" | ||||
| 		aria-hidden="true"> | ||||
| 	<div | ||||
| 		id="AboutSpamAnalysis" | ||||
| 		class="modal fade" | ||||
| 		tabindex="-1" | ||||
| 		aria-labelledby="AboutSpamAnalysisLabel" | ||||
| 		aria-hidden="true" | ||||
| 	> | ||||
| 		<div class="modal-dialog modal-lg modal-dialog-scrollable"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h1 class="modal-title fs-5" id="AboutSpamAnalysisLabel">About Spam Analysis</h1> | ||||
| 					<h1 id="AboutSpamAnalysisLabel" class="modal-title fs-5">About Spam Analysis</h1> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<div class="accordion" id="SpamAnalysisAboutAccordion"> | ||||
| 					<div id="SpamAnalysisAboutAccordion" class="accordion"> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col1" aria-expanded="false" aria-controls="col1"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col1" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col1" | ||||
| 								> | ||||
| 									What is Spam Analysis? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col1" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col1" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									<p> | ||||
| 										Mailpit integrates with SpamAssassin to provide you with some insight into the | ||||
| @@ -226,13 +250,22 @@ export default { | ||||
| 						</div> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col2" aria-expanded="false" aria-controls="col2"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col2" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col2" | ||||
| 								> | ||||
| 									How does the point system work? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col2" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col2" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									<p> | ||||
| 										The default spam threshold is <code>5</code>, meaning any score lower than 5 is | ||||
| @@ -248,18 +281,27 @@ export default { | ||||
| 						</div> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col3" aria-expanded="false" aria-controls="col3"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col3" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col3" | ||||
| 								> | ||||
| 									But I don't agree with the results... | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col3" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col3" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									<p> | ||||
| 										Mailpit does not manipulate the results nor determine the "spamminess" of | ||||
| 										your message. The result is what SpamAssassin returns, and it entirely | ||||
| 										dependent on how SpamAssassin is set up and optionally trained. | ||||
| 										Mailpit does not manipulate the results nor determine the "spamminess" of your | ||||
| 										message. The result is what SpamAssassin returns, and it entirely dependent on | ||||
| 										how SpamAssassin is set up and optionally trained. | ||||
| 									</p> | ||||
| 									<p> | ||||
| 										This tool is simply provided as an aid to assist you. If you are running your | ||||
| @@ -271,20 +313,31 @@ export default { | ||||
| 						</div> | ||||
| 						<div class="accordion-item"> | ||||
| 							<h2 class="accordion-header"> | ||||
| 								<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col4" aria-expanded="false" aria-controls="col4"> | ||||
| 								<button | ||||
| 									class="accordion-button collapsed" | ||||
| 									type="button" | ||||
| 									data-bs-toggle="collapse" | ||||
| 									data-bs-target="#col4" | ||||
| 									aria-expanded="false" | ||||
| 									aria-controls="col4" | ||||
| 								> | ||||
| 									Where can I find more information about the triggered rules? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<div id="col4" class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion"> | ||||
| 							<div | ||||
| 								id="col4" | ||||
| 								class="accordion-collapse collapse" | ||||
| 								data-bs-parent="#SpamAnalysisAboutAccordion" | ||||
| 							> | ||||
| 								<div class="accordion-body"> | ||||
| 									<p> | ||||
| 										Unfortunately the current <a href="https://spamassassin.apache.org/" | ||||
| 											target="_blank">SpamAssassin website</a> no longer contains any relative | ||||
| 										documentation about these, most likely because the rules come from different | ||||
| 										locations and change often. You will need to search the internet for these | ||||
| 										yourself. | ||||
| 										Unfortunately the current | ||||
| 										<a href="https://spamassassin.apache.org/" target="_blank" | ||||
| 											>SpamAssassin website</a | ||||
| 										> | ||||
| 										no longer contains any relative documentation about these, most likely because | ||||
| 										the rules come from different locations and change often. You will need to | ||||
| 										search the internet for these yourself. | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
|   | ||||
| @@ -1,19 +1,18 @@ | ||||
| import axios from 'axios' | ||||
| import dayjs from 'dayjs' | ||||
| import ColorHash from 'color-hash' | ||||
| import { Modal, Offcanvas } from 'bootstrap' | ||||
| import axios from "axios"; | ||||
| import dayjs from "dayjs"; | ||||
| import ColorHash from "color-hash"; | ||||
| import { Modal, Offcanvas } from "bootstrap"; | ||||
| import { limitOptions } from "../stores/pagination"; | ||||
|  | ||||
| // BootstrapElement is used to return a fake Bootstrap element | ||||
| // if the ID returns nothing to prevent errors. | ||||
| class BootstrapElement { | ||||
| 	constructor() { } | ||||
| 	hide() { } | ||||
| 	show() { } | ||||
| 	hide() {} | ||||
| 	show() {} | ||||
| } | ||||
|  | ||||
| // Set up the color hash generator lightness and hue to ensure darker colors | ||||
| const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] }) | ||||
| const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] }); | ||||
|  | ||||
| /* Common mixin functions used in apps */ | ||||
| export default { | ||||
| @@ -21,89 +20,89 @@ export default { | ||||
| 		return { | ||||
| 			loading: 0, | ||||
| 			tagColorCache: {}, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		resolve(u) { | ||||
| 			return this.$router.resolve(u).href | ||||
| 			return this.$router.resolve(u).href; | ||||
| 		}, | ||||
|  | ||||
| 		searchURI(s) { | ||||
| 			return this.resolve('/search') + '?q=' + encodeURIComponent(s) | ||||
| 			return this.resolve("/search") + "?q=" + encodeURIComponent(s); | ||||
| 		}, | ||||
|  | ||||
| 		getFileSize(bytes) { | ||||
| 			if (bytes == 0) { | ||||
| 				return '0B' | ||||
| 			if (bytes === 0) { | ||||
| 				return "0B"; | ||||
| 			} | ||||
| 			var i = Math.floor(Math.log(bytes) / Math.log(1024)) | ||||
| 			return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i] | ||||
| 			const i = Math.floor(Math.log(bytes) / Math.log(1024)); | ||||
| 			return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; | ||||
| 		}, | ||||
|  | ||||
| 		formatNumber(nr) { | ||||
| 			return new Intl.NumberFormat().format(nr) | ||||
| 			return new Intl.NumberFormat().format(nr); | ||||
| 		}, | ||||
|  | ||||
| 		messageDate(d) { | ||||
| 			return dayjs(d).format('ddd, D MMM YYYY, h:mm a') | ||||
| 			return dayjs(d).format("ddd, D MMM YYYY, h:mm a"); | ||||
| 		}, | ||||
|  | ||||
| 		secondsToRelative(d) { | ||||
| 			return dayjs().subtract(d, 'seconds').fromNow() | ||||
| 			return dayjs().subtract(d, "seconds").fromNow(); | ||||
| 		}, | ||||
|  | ||||
| 		tagEncodeURI(tag) { | ||||
| 			if (tag.match(/ /)) { | ||||
| 				tag = `"${tag}"` | ||||
| 				tag = `"${tag}"`; | ||||
| 			} | ||||
|  | ||||
| 			return encodeURIComponent(`tag:${tag}`) | ||||
| 			return encodeURIComponent(`tag:${tag}`); | ||||
| 		}, | ||||
|  | ||||
| 		getSearch() { | ||||
| 			if (!window.location.search) { | ||||
| 				return false | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			const urlParams = new URLSearchParams(window.location.search) | ||||
| 			const q = urlParams.get('q')?.trim() | ||||
| 			const urlParams = new URLSearchParams(window.location.search); | ||||
| 			const q = urlParams.get("q")?.trim(); | ||||
| 			if (!q) { | ||||
| 				return false | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			return q | ||||
| 			return q; | ||||
| 		}, | ||||
|  | ||||
| 		getPaginationParams() { | ||||
| 			if (!window.location.search) { | ||||
| 				return null | ||||
| 				return null; | ||||
| 			} | ||||
|  | ||||
| 			const urlParams = new URLSearchParams(window.location.search) | ||||
| 			const start = parseInt(urlParams.get('start')?.trim(), 10) | ||||
| 			const limit = parseInt(urlParams.get('limit')?.trim(), 10) | ||||
| 			const urlParams = new URLSearchParams(window.location.search); | ||||
| 			const start = parseInt(urlParams.get("start")?.trim(), 10); | ||||
| 			const limit = parseInt(urlParams.get("limit")?.trim(), 10); | ||||
| 			return { | ||||
| 				start: Number.isInteger(start) && start >= 0 ? start : null, | ||||
| 				limit: limitOptions.includes(limit) ? limit : null, | ||||
| 			} | ||||
| 			}; | ||||
| 		}, | ||||
|  | ||||
| 		// generic modal get/set function | ||||
| 		modal(id) { | ||||
| 			const e = document.getElementById(id) | ||||
| 			const e = document.getElementById(id); | ||||
| 			if (e) { | ||||
| 				return Modal.getOrCreateInstance(e) | ||||
| 				return Modal.getOrCreateInstance(e); | ||||
| 			} | ||||
| 			// in case there are open/close actions | ||||
| 			return new BootstrapElement() | ||||
| 			return new BootstrapElement(); | ||||
| 		}, | ||||
|  | ||||
| 		// close mobile navigation | ||||
| 		hideNav() { | ||||
| 			const e = document.getElementById('offcanvas') | ||||
| 			const e = document.getElementById("offcanvas"); | ||||
| 			if (e) { | ||||
| 				Offcanvas.getOrCreateInstance(e).hide() | ||||
| 				Offcanvas.getOrCreateInstance(e).hide(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| @@ -117,23 +116,24 @@ export default { | ||||
| 		 */ | ||||
| 		get(url, values, callback, errorCallback, hideLoader) { | ||||
| 			if (!hideLoader) { | ||||
| 				this.loading++ | ||||
| 				this.loading++; | ||||
| 			} | ||||
| 			axios.get(url, { params: values }) | ||||
| 			axios | ||||
| 				.get(url, { params: values }) | ||||
| 				.then(callback) | ||||
| 				.catch((err) => { | ||||
| 					if (typeof errorCallback == 'function') { | ||||
| 						return errorCallback(err) | ||||
| 					if (typeof errorCallback === "function") { | ||||
| 						return errorCallback(err); | ||||
| 					} | ||||
|  | ||||
| 					this.handleError(err) | ||||
| 					this.handleError(err); | ||||
| 				}) | ||||
| 				.then(() => { | ||||
| 					// always executed | ||||
| 					if (!hideLoader && this.loading > 0) { | ||||
| 						this.loading-- | ||||
| 						this.loading--; | ||||
| 					} | ||||
| 				}) | ||||
| 				}); | ||||
| 		}, | ||||
|  | ||||
| 		/** | ||||
| @@ -144,16 +144,17 @@ export default { | ||||
| 		 * @params function callback function | ||||
| 		 */ | ||||
| 		post(url, data, callback) { | ||||
| 			this.loading++ | ||||
| 			axios.post(url, data) | ||||
| 			this.loading++; | ||||
| 			axios | ||||
| 				.post(url, data) | ||||
| 				.then(callback) | ||||
| 				.catch(this.handleError) | ||||
| 				.then(() => { | ||||
| 					// always executed | ||||
| 					if (this.loading > 0) { | ||||
| 						this.loading-- | ||||
| 						this.loading--; | ||||
| 					} | ||||
| 				}) | ||||
| 				}); | ||||
| 		}, | ||||
|  | ||||
| 		/** | ||||
| @@ -164,16 +165,17 @@ export default { | ||||
| 		 * @params function callback function | ||||
| 		 */ | ||||
| 		delete(url, data, callback) { | ||||
| 			this.loading++ | ||||
| 			axios.delete(url, { data: data }) | ||||
| 			this.loading++; | ||||
| 			axios | ||||
| 				.delete(url, { data }) | ||||
| 				.then(callback) | ||||
| 				.catch(this.handleError) | ||||
| 				.then(() => { | ||||
| 					// always executed | ||||
| 					if (this.loading > 0) { | ||||
| 						this.loading-- | ||||
| 						this.loading--; | ||||
| 					} | ||||
| 				}) | ||||
| 				}); | ||||
| 		}, | ||||
|  | ||||
| 		/** | ||||
| @@ -184,16 +186,17 @@ export default { | ||||
| 		 * @params function callback function | ||||
| 		 */ | ||||
| 		put(url, data, callback) { | ||||
| 			this.loading++ | ||||
| 			axios.put(url, data) | ||||
| 			this.loading++; | ||||
| 			axios | ||||
| 				.put(url, data) | ||||
| 				.then(callback) | ||||
| 				.catch(this.handleError) | ||||
| 				.then(() => { | ||||
| 					// always executed | ||||
| 					if (this.loading > 0) { | ||||
| 						this.loading-- | ||||
| 						this.loading--; | ||||
| 					} | ||||
| 				}) | ||||
| 				}); | ||||
| 		}, | ||||
|  | ||||
| 		// Ajax error message | ||||
| @@ -203,87 +206,87 @@ export default { | ||||
| 				// The request was made and the server responded with a status code | ||||
| 				// that falls out of the range of 2xx | ||||
| 				if (error.response.data.Error) { | ||||
| 					alert(error.response.data.Error) | ||||
| 					alert(error.response.data.Error); | ||||
| 				} else { | ||||
| 					alert(error.response.data) | ||||
| 					alert(error.response.data); | ||||
| 				} | ||||
| 			} else if (error.request) { | ||||
| 				// The request was made but no response was received | ||||
| 				alert('Error sending data to the server. Please try again.') | ||||
| 				alert("Error sending data to the server. Please try again."); | ||||
| 			} else { | ||||
| 				// Something happened in setting up the request that triggered an Error | ||||
| 				alert(error.message) | ||||
| 				alert(error.message); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		allAttachments(message) { | ||||
| 			let a = [] | ||||
| 			for (let i in message.Attachments) { | ||||
| 				a.push(message.Attachments[i]) | ||||
| 			const a = []; | ||||
| 			for (const i in message.Attachments) { | ||||
| 				a.push(message.Attachments[i]); | ||||
| 			} | ||||
| 			for (let i in message.OtherParts) { | ||||
| 				a.push(message.OtherParts[i]) | ||||
| 			for (const i in message.OtherParts) { | ||||
| 				a.push(message.OtherParts[i]); | ||||
| 			} | ||||
| 			for (let i in message.Inline) { | ||||
| 				a.push(message.Inline[i]) | ||||
| 			for (const i in message.Inline) { | ||||
| 				a.push(message.Inline[i]); | ||||
| 			} | ||||
|  | ||||
| 			return a.length ? a : false | ||||
| 			return a.length ? a : false; | ||||
| 		}, | ||||
|  | ||||
| 		isImage(a) { | ||||
| 			return a.ContentType.match(/^image\//) | ||||
| 			return a.ContentType.match(/^image\//); | ||||
| 		}, | ||||
|  | ||||
| 		attachmentIcon(a) { | ||||
| 			let ext = a.FileName.split('.').pop().toLowerCase() | ||||
| 			const ext = a.FileName.split(".").pop().toLowerCase(); | ||||
|  | ||||
| 			if (a.ContentType.match(/^image\//)) { | ||||
| 				return 'bi-file-image-fill' | ||||
| 				return "bi-file-image-fill"; | ||||
| 			} | ||||
| 			if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') { | ||||
| 				return 'bi-file-pdf-fill' | ||||
| 			if (a.ContentType.match(/\/pdf$/) || ext === "pdf") { | ||||
| 				return "bi-file-pdf-fill"; | ||||
| 			} | ||||
| 			if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) { | ||||
| 				return 'bi-file-word-fill' | ||||
| 			if (["doc", "docx", "odt", "rtf"].includes(ext)) { | ||||
| 				return "bi-file-word-fill"; | ||||
| 			} | ||||
| 			if (['xls', 'xlsx', 'ods'].includes(ext)) { | ||||
| 				return 'bi-file-spreadsheet-fill' | ||||
| 			if (["xls", "xlsx", "ods"].includes(ext)) { | ||||
| 				return "bi-file-spreadsheet-fill"; | ||||
| 			} | ||||
| 			if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) { | ||||
| 				return 'bi-file-slides-fill' | ||||
| 			if (["ppt", "pptx", "key", "ppt", "odp"].includes(ext)) { | ||||
| 				return "bi-file-slides-fill"; | ||||
| 			} | ||||
| 			if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) { | ||||
| 				return 'bi-file-zip-fill' | ||||
| 			if (["zip", "tar", "rar", "bz2", "gz", "xz"].includes(ext)) { | ||||
| 				return "bi-file-zip-fill"; | ||||
| 			} | ||||
| 			if (['ics'].includes(ext)) { | ||||
| 				return 'bi-calendar-event' | ||||
| 			if (["ics"].includes(ext)) { | ||||
| 				return "bi-calendar-event"; | ||||
| 			} | ||||
| 			if (a.ContentType.match(/^audio\//)) { | ||||
| 				return 'bi-file-music-fill' | ||||
| 				return "bi-file-music-fill"; | ||||
| 			} | ||||
| 			if (a.ContentType.match(/^video\//)) { | ||||
| 				return 'bi-file-play-fill' | ||||
| 				return "bi-file-play-fill"; | ||||
| 			} | ||||
| 			if (a.ContentType.match(/\/calendar$/)) { | ||||
| 				return 'bi-file-check-fill' | ||||
| 				return "bi-file-check-fill"; | ||||
| 			} | ||||
| 			if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) { | ||||
| 				return 'bi-file-text-fill' | ||||
| 			if (a.ContentType.match(/^text\//) || ["txt", "sh", "log"].includes(ext)) { | ||||
| 				return "bi-file-text-fill"; | ||||
| 			} | ||||
|  | ||||
| 			return 'bi-file-arrow-down-fill' | ||||
| 			return "bi-file-arrow-down-fill"; | ||||
| 		}, | ||||
|  | ||||
| 		// Returns a hex color based on a string. | ||||
| 		// Values are stored in an array for faster lookup / processing. | ||||
| 		colorHash(s) { | ||||
| 			if (this.tagColorCache[s] != undefined) { | ||||
| 				return this.tagColorCache[s] | ||||
| 			if (this.tagColorCache[s] !== undefined) { | ||||
| 				return this.tagColorCache[s]; | ||||
| 			} | ||||
| 			this.tagColorCache[s] = colorHash.hex(s) | ||||
| 			this.tagColorCache[s] = colorHash.hex(s); | ||||
|  | ||||
| 			return this.tagColorCache[s] | ||||
| 			return this.tagColorCache[s]; | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import CommonMixins from './CommonMixins.js' | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
| import { pagination } from '../stores/pagination.js' | ||||
| import CommonMixins from "./CommonMixins.js"; | ||||
| import { mailbox } from "../stores/mailbox.js"; | ||||
| import { pagination } from "../stores/pagination.js"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| @@ -10,88 +10,86 @@ export default { | ||||
| 			apiURI: false, | ||||
| 			pagination, | ||||
| 			mailbox, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		'mailbox.refresh': function (v) { | ||||
| 		"mailbox.refresh": function (v) { | ||||
| 			if (v) { | ||||
| 				// trigger a refresh | ||||
| 				this.loadMessages() | ||||
| 				this.loadMessages(); | ||||
| 			} | ||||
|  | ||||
| 			mailbox.refresh = false | ||||
| 		} | ||||
| 			mailbox.refresh = false; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		reloadMailbox() { | ||||
| 			pagination.start = 0 | ||||
| 			this.loadMessages() | ||||
| 			pagination.start = 0; | ||||
| 			this.loadMessages(); | ||||
| 		}, | ||||
|  | ||||
| 		loadMessages() { | ||||
| 			if (!this.apiURI) { | ||||
| 				alert('apiURL not set!') | ||||
| 				return | ||||
| 				alert("apiURL not set!"); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// auto-pagination changes the URL but should not fetch new messages | ||||
| 			// when viewing page > 0 and new messages are received (inbox only) | ||||
| 			if (!mailbox.autoPaginating) { | ||||
| 				mailbox.autoPaginating = true // reset | ||||
| 				return | ||||
| 				mailbox.autoPaginating = true; // reset | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const params = {} | ||||
| 			mailbox.selected = [] | ||||
| 			const params = {}; | ||||
| 			mailbox.selected = []; | ||||
|  | ||||
| 			params['limit'] = pagination.limit | ||||
| 			params["limit"] = pagination.limit; | ||||
| 			if (pagination.start > 0) { | ||||
| 				params['start'] = pagination.start | ||||
| 				params["start"] = pagination.start; | ||||
| 			} | ||||
|  | ||||
| 			this.get(this.apiURI, params, (response) => { | ||||
| 				mailbox.total = response.data.total // all messages | ||||
| 				mailbox.unread = response.data.unread // all unread messages | ||||
| 				mailbox.tags = response.data.tags // all tags | ||||
| 				mailbox.messages = response.data.messages // current messages | ||||
| 				mailbox.count = response.data.messages_count // total results for this mailbox/search | ||||
| 				mailbox.messages_unread = response.data.messages_unread // total unread results for this mailbox/search | ||||
| 				mailbox.total = response.data.total; // all messages | ||||
| 				mailbox.unread = response.data.unread; // all unread messages | ||||
| 				mailbox.tags = response.data.tags; // all tags | ||||
| 				mailbox.messages = response.data.messages; // current messages | ||||
| 				mailbox.count = response.data.messages_count; // total results for this mailbox/search | ||||
| 				mailbox.messages_unread = response.data.messages_unread; // total unread results for this mailbox/search | ||||
| 				// ensure the pagination remains consistent | ||||
| 				pagination.start = response.data.start | ||||
| 				pagination.start = response.data.start; | ||||
|  | ||||
| 				if (response.data.count == 0 && response.data.start > 0) { | ||||
| 					pagination.start = 0 | ||||
| 					return this.loadMessages() | ||||
| 				if (response.data.count === 0 && response.data.start > 0) { | ||||
| 					pagination.start = 0; | ||||
| 					return this.loadMessages(); | ||||
| 				} | ||||
|  | ||||
| 				if (mailbox.lastMessage) { | ||||
| 					window.setTimeout(() => { | ||||
| 						const m = document.getElementById(mailbox.lastMessage) | ||||
| 						const m = document.getElementById(mailbox.lastMessage); | ||||
| 						if (m) { | ||||
| 							m.focus() | ||||
| 							m.focus(); | ||||
| 							// m.scrollIntoView({ behavior: 'smooth', block: 'center' }) | ||||
| 							m.scrollIntoView({ block: 'center' }) | ||||
| 							m.scrollIntoView({ block: "center" }); | ||||
| 						} else { | ||||
| 							const mp = document.getElementById('message-page') | ||||
| 							const mp = document.getElementById("message-page"); | ||||
| 							if (mp) { | ||||
| 								mp.scrollTop = 0 | ||||
| 								mp.scrollTop = 0; | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 						mailbox.lastMessage = false | ||||
| 					}, 50) | ||||
|  | ||||
| 						mailbox.lastMessage = false; | ||||
| 					}, 50); | ||||
| 				} else if (!window.scrollInPlace) { | ||||
| 					const mp = document.getElementById('message-page') | ||||
| 					const mp = document.getElementById("message-page"); | ||||
| 					if (mp) { | ||||
| 						mp.scrollTop = 0 | ||||
| 						mp.scrollTop = 0; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				window.scrollInPlace = false | ||||
| 			}) | ||||
| 				window.scrollInPlace = false; | ||||
| 			}); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| import { createRouter, createWebHistory } from 'vue-router' | ||||
| import MailboxView from '../views/MailboxView.vue' | ||||
| import MessageView from '../views/MessageView.vue' | ||||
| import NotFoundView from '../views/NotFoundView.vue' | ||||
| import SearchView from '../views/SearchView.vue' | ||||
| import { createRouter, createWebHistory } from "vue-router"; | ||||
| import MailboxView from "../views/MailboxView.vue"; | ||||
| import MessageView from "../views/MessageView.vue"; | ||||
| import NotFoundView from "../views/NotFoundView.vue"; | ||||
| import SearchView from "../views/SearchView.vue"; | ||||
|  | ||||
| let d = document.getElementById('app') | ||||
| let webroot = '/' | ||||
| const d = document.getElementById("app"); | ||||
| let webroot = "/"; | ||||
| if (d) { | ||||
| 	webroot = d.dataset.webroot | ||||
| 	webroot = d.dataset.webroot; | ||||
| } | ||||
|  | ||||
| // paths are relative to webroot | ||||
| @@ -15,23 +15,23 @@ const router = createRouter({ | ||||
| 	history: createWebHistory(webroot), | ||||
| 	routes: [ | ||||
| 		{ | ||||
| 			path: '/', | ||||
| 			component: MailboxView | ||||
| 			path: "/", | ||||
| 			component: MailboxView, | ||||
| 		}, | ||||
| 		{ | ||||
| 			path: '/search', | ||||
| 			component: SearchView | ||||
| 			path: "/search", | ||||
| 			component: SearchView, | ||||
| 		}, | ||||
| 		{ | ||||
| 			path: '/view/:id', | ||||
| 			component: MessageView | ||||
| 			path: "/view/:id", | ||||
| 			component: MessageView, | ||||
| 		}, | ||||
| 		{ | ||||
| 			path: '/:pathMatch(.*)*', | ||||
| 			name: 'NotFound', | ||||
| 			component: NotFoundView | ||||
| 		} | ||||
| 	] | ||||
| }) | ||||
| 			path: "/:pathMatch(.*)*", | ||||
| 			name: "NotFound", | ||||
| 			component: NotFoundView, | ||||
| 		}, | ||||
| 	], | ||||
| }); | ||||
|  | ||||
| export default router | ||||
| export default router; | ||||
|   | ||||
| @@ -1,92 +1,94 @@ | ||||
| // State Management | ||||
|  | ||||
| import { reactive, watch } from 'vue' | ||||
| import { reactive, watch } from "vue"; | ||||
|  | ||||
| // global mailbox info | ||||
| export const mailbox = reactive({ | ||||
| 	total: 0,						// total number of messages in database | ||||
| 	unread: 0,						// total unread messages in database | ||||
| 	count: 0,						// total in mailbox or search | ||||
| 	messages: [],					// current messages | ||||
| 	tags: [], 						// all tags | ||||
| 	selected: [], 					// currently selected | ||||
| 	connected: false, 				// websocket connection | ||||
| 	searching: false,				// current search, false for none | ||||
| 	refresh: false, 				// to listen from MessagesMixin | ||||
| 	autoPaginating: true, 			// allows temporary bypass of loadMessages() via auto-pagination | ||||
| 	notificationsSupported: false,	// browser supports notifications | ||||
| 	notificationsEnabled: false,	// user has enabled notifications | ||||
| 	skipConfirmations: false, 		// skip modal confirmations for "Delete all" & "mark all read" | ||||
| 	appInfo: {},					// application information | ||||
| 	uiConfig: {},					// configuration for UI | ||||
| 	lastMessage: false,				// return scrolling | ||||
| 	total: 0, // total number of messages in database | ||||
| 	unread: 0, // total unread messages in database | ||||
| 	count: 0, // total in mailbox or search | ||||
| 	messages: [], // current messages | ||||
| 	tags: [], // all tags | ||||
| 	selected: [], // currently selected | ||||
| 	connected: false, // websocket connection | ||||
| 	searching: false, // current search, false for none | ||||
| 	refresh: false, // to listen from MessagesMixin | ||||
| 	autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination | ||||
| 	notificationsSupported: false, // browser supports notifications | ||||
| 	notificationsEnabled: false, // user has enabled notifications | ||||
| 	skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read" | ||||
| 	appInfo: {}, // application information | ||||
| 	uiConfig: {}, // configuration for UI | ||||
| 	lastMessage: false, // return scrolling | ||||
|  | ||||
| 	// settings | ||||
| 	showTagColors: !localStorage.getItem('hideTagColors') == '1', | ||||
| 	showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1', | ||||
| 	showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1', | ||||
| 	showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1', | ||||
| 	timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone, | ||||
| }) | ||||
| 	showTagColors: !localStorage.getItem("hideTagColors"), | ||||
| 	showHTMLCheck: !localStorage.getItem("hideHTMLCheck"), | ||||
| 	showLinkCheck: !localStorage.getItem("hideLinkCheck"), | ||||
| 	showSpamCheck: !localStorage.getItem("hideSpamCheck"), | ||||
| 	timeZone: localStorage.getItem("timeZone") | ||||
| 		? localStorage.getItem("timeZone") | ||||
| 		: Intl.DateTimeFormat().resolvedOptions().timeZone, | ||||
| }); | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.count, | ||||
| 	(v) => { | ||||
| 		mailbox.selected = [] | ||||
| 	} | ||||
| ) | ||||
| 		mailbox.selected = []; | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.showTagColors, | ||||
| 	(v) => { | ||||
| 		if (v) { | ||||
| 			localStorage.removeItem('hideTagColors') | ||||
| 			localStorage.removeItem("hideTagColors"); | ||||
| 		} else { | ||||
| 			localStorage.setItem('hideTagColors', '1') | ||||
| 			localStorage.setItem("hideTagColors", "1"); | ||||
| 		} | ||||
| 	} | ||||
| ) | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.showHTMLCheck, | ||||
| 	(v) => { | ||||
| 		if (v) { | ||||
| 			localStorage.removeItem('hideHTMLCheck') | ||||
| 			localStorage.removeItem("hideHTMLCheck"); | ||||
| 		} else { | ||||
| 			localStorage.setItem('hideHTMLCheck', '1') | ||||
| 			localStorage.setItem("hideHTMLCheck", "1"); | ||||
| 		} | ||||
| 	} | ||||
| ) | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.showLinkCheck, | ||||
| 	(v) => { | ||||
| 		if (v) { | ||||
| 			localStorage.removeItem('hideLinkCheck') | ||||
| 			localStorage.removeItem("hideLinkCheck"); | ||||
| 		} else { | ||||
| 			localStorage.setItem('hideLinkCheck', '1') | ||||
| 			localStorage.setItem("hideLinkCheck", "1"); | ||||
| 		} | ||||
| 	} | ||||
| ) | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.showSpamCheck, | ||||
| 	(v) => { | ||||
| 		if (v) { | ||||
| 			localStorage.removeItem('hideSpamCheck') | ||||
| 			localStorage.removeItem("hideSpamCheck"); | ||||
| 		} else { | ||||
| 			localStorage.setItem('hideSpamCheck', '1') | ||||
| 			localStorage.setItem("hideSpamCheck", "1"); | ||||
| 		} | ||||
| 	} | ||||
| ) | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.timeZone, | ||||
| 	(v) => { | ||||
| 		if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) { | ||||
| 			localStorage.removeItem('timeZone') | ||||
| 		if (v === Intl.DateTimeFormat().resolvedOptions().timeZone) { | ||||
| 			localStorage.removeItem("timeZone"); | ||||
| 		} else { | ||||
| 			localStorage.setItem('timeZone', v) | ||||
| 			localStorage.setItem("timeZone", v); | ||||
| 		} | ||||
| 	} | ||||
| ) | ||||
| 	}, | ||||
| ); | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { reactive } from 'vue' | ||||
| import { reactive } from "vue"; | ||||
|  | ||||
| export const pagination = reactive({ | ||||
| 	start: 0,	// pagination offset | ||||
| 	limit: 50, 	// per page | ||||
| 	start: 0, // pagination offset | ||||
| 	limit: 50, // per page | ||||
| 	defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit | ||||
| 	total: 0,  	// total results of current view / filter | ||||
| 	count: 0, 	// number of messages currently displayed | ||||
| }) | ||||
| 	total: 0, // total results of current view / filter | ||||
| 	count: 0, // number of messages currently displayed | ||||
| }); | ||||
|  | ||||
| export const limitOptions = [25, 50, 100, 200] | ||||
| export const limitOptions = [25, 50, 100, 200]; | ||||
|   | ||||
| @@ -1,24 +1,19 @@ | ||||
| <script> | ||||
| import AboutMailpit from '../components/AboutMailpit.vue' | ||||
| import AjaxLoader from '../components/AjaxLoader.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import ListMessages from '../components/ListMessages.vue' | ||||
| import MessagesMixins from '../mixins/MessagesMixins' | ||||
| import NavMailbox from '../components/NavMailbox.vue' | ||||
| import NavTags from '../components/NavTags.vue' | ||||
| import Pagination from '../components/Pagination.vue' | ||||
| import SearchForm from '../components/SearchForm.vue' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import About from "../components/AppAbout.vue"; | ||||
| import AjaxLoader from "../components/AjaxLoader.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import ListMessages from "../components/ListMessages.vue"; | ||||
| import MessagesMixins from "../mixins/MessagesMixins"; | ||||
| import NavMailbox from "../components/NavMailbox.vue"; | ||||
| import NavTags from "../components/NavTags.vue"; | ||||
| import Pagination from "../components/NavPagination.vue"; | ||||
| import SearchForm from "../components/SearchForm.vue"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins, MessagesMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	components: { | ||||
| 		AboutMailpit, | ||||
| 		About, | ||||
| 		AjaxLoader, | ||||
| 		ListMessages, | ||||
| 		NavMailbox, | ||||
| @@ -27,111 +22,119 @@ export default { | ||||
| 		SearchForm, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [CommonMixins, MessagesMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			delayedRefresh: false, | ||||
| 			paginationDelayed: false, // for delayed pagination URL changes | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		$route(to, from) { | ||||
| 			this.loadMailbox() | ||||
| 		} | ||||
| 			this.loadMailbox(); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		mailbox.searching = false | ||||
| 		this.apiURI = this.resolve(`/api/v1/messages`) | ||||
| 		this.loadMailbox() | ||||
| 		mailbox.searching = false; | ||||
| 		this.apiURI = this.resolve(`/api/v1/messages`); | ||||
| 		this.loadMailbox(); | ||||
|  | ||||
| 		// subscribe to events | ||||
| 		this.eventBus.on("new", this.handleWSNew) | ||||
| 		this.eventBus.on("update", this.handleWSUpdate) | ||||
| 		this.eventBus.on("delete", this.handleWSDelete) | ||||
| 		this.eventBus.on("truncate", this.handleWSTruncate) | ||||
| 		this.eventBus.on("new", this.handleWSNew); | ||||
| 		this.eventBus.on("update", this.handleWSUpdate); | ||||
| 		this.eventBus.on("delete", this.handleWSDelete); | ||||
| 		this.eventBus.on("truncate", this.handleWSTruncate); | ||||
| 	}, | ||||
|  | ||||
| 	unmounted() { | ||||
| 		// unsubscribe from events | ||||
| 		this.eventBus.off("new", this.handleWSNew) | ||||
| 		this.eventBus.off("update", this.handleWSUpdate) | ||||
| 		this.eventBus.off("delete", this.handleWSDelete) | ||||
| 		this.eventBus.off("truncate", this.handleWSTruncate) | ||||
| 		this.eventBus.off("new", this.handleWSNew); | ||||
| 		this.eventBus.off("update", this.handleWSUpdate); | ||||
| 		this.eventBus.off("delete", this.handleWSDelete); | ||||
| 		this.eventBus.off("truncate", this.handleWSTruncate); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		loadMailbox() { | ||||
| 			const paginationParams = this.getPaginationParams() | ||||
| 			const paginationParams = this.getPaginationParams(); | ||||
| 			if (paginationParams?.start) { | ||||
| 				pagination.start = paginationParams.start | ||||
| 				pagination.start = paginationParams.start; | ||||
| 			} else { | ||||
| 				pagination.start = 0 | ||||
| 				pagination.start = 0; | ||||
| 			} | ||||
| 			if (paginationParams?.limit) { | ||||
| 				pagination.limit = paginationParams.limit | ||||
| 				pagination.limit = paginationParams.limit; | ||||
| 			} | ||||
|  | ||||
| 			this.loadMessages() | ||||
| 			this.loadMessages(); | ||||
| 		}, | ||||
|  | ||||
| 		// This will only update the pagination offset at a maximum of 2x per second | ||||
| 		// when viewing the inbox on > page 1, while receiving an influx of new messages. | ||||
| 		delayedPaginationUpdate() { | ||||
| 			if (this.paginationDelayed) { | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			this.paginationDelayed = true | ||||
| 			this.paginationDelayed = true; | ||||
|  | ||||
| 			window.setTimeout(() => { | ||||
| 				const path = this.$route.path | ||||
| 				const path = this.$route.path; | ||||
| 				const p = { | ||||
| 					...this.$route.query | ||||
| 				} | ||||
| 					...this.$route.query, | ||||
| 				}; | ||||
| 				if (pagination.start > 0) { | ||||
| 					p.start = pagination.start.toString() | ||||
| 					p.start = pagination.start.toString(); | ||||
| 				} else { | ||||
| 					delete p.start | ||||
| 					delete p.start; | ||||
| 				} | ||||
| 				if (pagination.limit != pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString() | ||||
| 				if (pagination.limit !== pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString(); | ||||
| 				} else { | ||||
| 					delete p.limit | ||||
| 					delete p.limit; | ||||
| 				} | ||||
|  | ||||
| 				mailbox.autoPaginating = false // prevent reload of messages when URL changes | ||||
| 				const params = new URLSearchParams(p) | ||||
| 				this.$router.replace(path + '?' + params.toString()) | ||||
| 				mailbox.autoPaginating = false; // prevent reload of messages when URL changes | ||||
| 				const params = new URLSearchParams(p); | ||||
| 				this.$router.replace(path + "?" + params.toString()); | ||||
|  | ||||
| 				this.paginationDelayed = false | ||||
| 			}, 500) | ||||
| 				this.paginationDelayed = false; | ||||
| 			}, 500); | ||||
| 		}, | ||||
|  | ||||
| 		// handler for websocket new messages | ||||
| 		handleWSNew(data) { | ||||
| 			if (pagination.start < 1) { | ||||
| 				// push results directly into first page | ||||
| 				mailbox.messages.unshift(data) | ||||
| 				mailbox.messages.unshift(data); | ||||
| 				if (mailbox.messages.length > pagination.limit) { | ||||
| 					mailbox.messages.pop() | ||||
| 					mailbox.messages.pop(); | ||||
| 				} | ||||
| 			} else { | ||||
| 				// update pagination offset | ||||
| 				pagination.start++ | ||||
| 				pagination.start++; | ||||
| 				// prevent "Too many calls to Location or History APIs within a short time frame" | ||||
| 				this.delayedPaginationUpdate() | ||||
| 				this.delayedPaginationUpdate(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		// handler for websocket message updates | ||||
| 		handleWSUpdate(data) { | ||||
| 			for (let x = 0; x < this.mailbox.messages.length; x++) { | ||||
| 				if (this.mailbox.messages[x].ID == data.ID) { | ||||
| 				if (this.mailbox.messages[x].ID === data.ID) { | ||||
| 					// update message | ||||
| 					this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data } | ||||
| 					return | ||||
| 					this.mailbox.messages[x] = { | ||||
| 						...this.mailbox.messages[x], | ||||
| 						...data, | ||||
| 					}; | ||||
| 					return; | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| @@ -140,43 +143,43 @@ export default { | ||||
| 		handleWSDelete(data) { | ||||
| 			let removed = 0; | ||||
| 			for (let x = 0; x < this.mailbox.messages.length; x++) { | ||||
| 				if (this.mailbox.messages[x].ID == data.ID) { | ||||
| 				if (this.mailbox.messages[x].ID === data.ID) { | ||||
| 					// remove message from the list | ||||
| 					this.mailbox.messages.splice(x, 1) | ||||
| 					removed++ | ||||
| 					continue | ||||
| 					this.mailbox.messages.splice(x, 1); | ||||
| 					removed++; | ||||
| 					continue; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (!removed || this.delayedRefresh) { | ||||
| 				// nothing changed on this screen, or a refresh is queued, | ||||
| 				// don't refresh | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// delayedRefresh prevents unnecessary reloads when multiple messages are deleted | ||||
| 			this.delayedRefresh = true | ||||
| 			this.delayedRefresh = true; | ||||
|  | ||||
| 			window.setTimeout(() => { | ||||
| 				this.delayedRefresh = false | ||||
| 				this.loadMessages() | ||||
| 			}, 500) | ||||
| 				this.delayedRefresh = false; | ||||
| 				this.loadMessages(); | ||||
| 			}, 500); | ||||
| 		}, | ||||
|  | ||||
| 		// handler for websocket message truncation | ||||
| 		handleWSTruncate() { | ||||
| 			// all messages gone, reload | ||||
| 			this.loadMessages() | ||||
| 			this.loadMessages(); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none"> | ||||
| 		<div class="col-xl-2 col-md-3 col-auto pe-0"> | ||||
| 			<RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit" /> | ||||
| 				<span class="ms-2 d-none d-sm-inline">Mailpit</span> | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
| @@ -185,8 +188,13 @@ export default { | ||||
| 		</div> | ||||
| 		<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0"> | ||||
| 			<div class="float-start d-md-none"> | ||||
| 				<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas" | ||||
| 					data-bs-target="#offcanvas" aria-controls="offcanvas"> | ||||
| 				<button | ||||
| 					class="btn btn-outline-light me-2" | ||||
| 					type="button" | ||||
| 					data-bs-toggle="offcanvas" | ||||
| 					data-bs-target="#offcanvas" | ||||
| 					aria-controls="offcanvas" | ||||
| 				> | ||||
| 					<i class="bi bi-list"></i> | ||||
| 				</button> | ||||
| 			</div> | ||||
| @@ -194,41 +202,51 @@ export default { | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas" | ||||
| 		aria-labelledby="offcanvasLabel"> | ||||
| 	<div | ||||
| 		id="offcanvas" | ||||
| 		class="offcanvas-md offcanvas-start d-md-none" | ||||
| 		data-bs-scroll="true" | ||||
| 		tabindex="-1" | ||||
| 		aria-labelledby="offcanvasLabel" | ||||
| 	> | ||||
| 		<div class="offcanvas-header"> | ||||
| 			<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5> | ||||
| 			<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas" | ||||
| 				aria-label="Close"></button> | ||||
| 			<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5> | ||||
| 			<button | ||||
| 				type="button" | ||||
| 				class="btn-close" | ||||
| 				data-bs-dismiss="offcanvas" | ||||
| 				data-bs-target="#offcanvas" | ||||
| 				aria-label="Close" | ||||
| 			></button> | ||||
| 		</div> | ||||
| 		<div class="offcanvas-body pb-0"> | ||||
| 			<div class="d-flex flex-column h-100"> | ||||
| 				<div class="flex-grow-1 overflow-y-auto me-n3 pe-3"> | ||||
| 					<NavMailbox @loadMessages="loadMessages" /> | ||||
| 					<NavMailbox @load-messages="loadMessages" /> | ||||
| 					<NavTags /> | ||||
| 				</div> | ||||
| 				<AboutMailpit /> | ||||
| 				<About /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<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="flex-grow-1 overflow-y-auto me-n3 pe-3"> | ||||
| 				<NavMailbox @loadMessages="loadMessages" /> | ||||
| 				<NavMailbox @load-messages="loadMessages" /> | ||||
| 				<NavTags /> | ||||
| 			</div> | ||||
| 			<AboutMailpit /> | ||||
| 			<About /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0"> | ||||
| 			<div class="mh-100" style="overflow-y: auto;" id="message-page"> | ||||
| 			<div id="message-page" class="mh-100" style="overflow-y: auto"> | ||||
| 				<ListMessages :loading-messages="loading" /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<NavMailbox @loadMessages="loadMessages" modals /> | ||||
| 	<AboutMailpit modals /> | ||||
| 	<NavMailbox modals @load-messages="loadMessages" /> | ||||
| 	<About modals /> | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
|   | ||||
| @@ -1,20 +1,15 @@ | ||||
| <script> | ||||
| import AboutMailpit from '../components/AboutMailpit.vue' | ||||
| import AjaxLoader from '../components/AjaxLoader.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import Message from '../components/message/Message.vue' | ||||
| import Release from '../components/message/Release.vue' | ||||
| import Screenshot from '../components/message/Screenshot.vue' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
| import dayjs from 'dayjs' | ||||
| import AboutMailpit from "../components/AppAbout.vue"; | ||||
| import AjaxLoader from "../components/AjaxLoader.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import Message from "../components/message/MessageItem.vue"; | ||||
| import Release from "../components/message/MessageRelease.vue"; | ||||
| import Screenshot from "../components/message/MessageScreenshot.vue"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
| import dayjs from "dayjs"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	components: { | ||||
| 		AboutMailpit, | ||||
| 		AjaxLoader, | ||||
| @@ -23,6 +18,11 @@ export default { | ||||
| 		Release, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| @@ -36,203 +36,206 @@ export default { | ||||
| 			liveLoaded: 0, // the number new messages prepended tp messageList | ||||
| 			scrollLoading: false, | ||||
| 			canLoadMore: true, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		$route(to, from) { | ||||
| 			this.loadMessage() | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	created() { | ||||
| 		const relativeTime = require('dayjs/plugin/relativeTime') | ||||
| 		dayjs.extend(relativeTime) | ||||
|  | ||||
| 		this.initLoadMoreAPIParams() | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.loadMessage() | ||||
|  | ||||
| 		this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages)) | ||||
| 		if (!this.messagesList.length) { | ||||
| 			this.loadMore() | ||||
| 		} | ||||
|  | ||||
| 		this.refreshUI() | ||||
|  | ||||
| 		// subscribe to events | ||||
| 		this.eventBus.on("new", this.handleWSNew) | ||||
| 		this.eventBus.on("update", this.handleWSUpdate) | ||||
| 		this.eventBus.on("delete", this.handleWSDelete) | ||||
| 		this.eventBus.on("truncate", this.handleWSTruncate) | ||||
| 	}, | ||||
|  | ||||
| 	unmounted() { | ||||
| 		// unsubscribe from events | ||||
| 		this.eventBus.off("new", this.handleWSNew) | ||||
| 		this.eventBus.off("update", this.handleWSUpdate) | ||||
| 		this.eventBus.off("delete", this.handleWSDelete) | ||||
| 		this.eventBus.off("truncate", this.handleWSTruncate) | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		// get current message read status | ||||
| 		isRead() { | ||||
| 			const l = this.messagesList.length | ||||
| 			const l = this.messagesList.length; | ||||
| 			if (!this.message || !l) { | ||||
| 				return true | ||||
| 				return true; | ||||
| 			} | ||||
|  | ||||
| 			let id = false | ||||
| 			for (x = 0; x < l; x++) { | ||||
| 				if (this.messagesList[x].ID == this.message.ID) { | ||||
| 					return this.messagesList[x].Read | ||||
| 			for (let x = 0; x < l; x++) { | ||||
| 				if (this.messagesList[x].ID === this.message.ID) { | ||||
| 					return this.messagesList[x].Read; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return true | ||||
| 			return true; | ||||
| 		}, | ||||
|  | ||||
| 		// get the previous message ID | ||||
| 		previousID() { | ||||
| 			const l = this.messagesList.length | ||||
| 			const l = this.messagesList.length; | ||||
| 			if (!this.message || !l) { | ||||
| 				return false | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			let id = false | ||||
| 			for (x = 0; x < l; x++) { | ||||
| 				if (this.messagesList[x].ID == this.message.ID) { | ||||
| 					return id | ||||
| 			let id = false; | ||||
| 			for (let x = 0; x < l; x++) { | ||||
| 				if (this.messagesList[x].ID === this.message.ID) { | ||||
| 					return id; | ||||
| 				} | ||||
| 				id = this.messagesList[x].ID | ||||
| 				id = this.messagesList[x].ID; | ||||
| 			} | ||||
|  | ||||
| 			return false | ||||
| 			return false; | ||||
| 		}, | ||||
|  | ||||
| 		// get the next message ID | ||||
| 		nextID() { | ||||
| 			const l = this.messagesList.length | ||||
| 			const l = this.messagesList.length; | ||||
| 			if (!this.message || !l) { | ||||
| 				return false | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			let id = false | ||||
| 			for (x = l - 1; x > 0; x--) { | ||||
| 				if (this.messagesList[x].ID == this.message.ID) { | ||||
| 					return id | ||||
| 			let id = false; | ||||
| 			for (let x = l - 1; x > 0; x--) { | ||||
| 				if (this.messagesList[x].ID === this.message.ID) { | ||||
| 					return id; | ||||
| 				} | ||||
| 				id = this.messagesList[x].ID | ||||
| 				id = this.messagesList[x].ID; | ||||
| 			} | ||||
|  | ||||
| 			return id | ||||
| 			return id; | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		$route(to, from) { | ||||
| 			this.loadMessage(); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	created() { | ||||
| 		const relativeTime = require("dayjs/plugin/relativeTime"); | ||||
| 		dayjs.extend(relativeTime); | ||||
|  | ||||
| 		this.initLoadMoreAPIParams(); | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.loadMessage(); | ||||
|  | ||||
| 		this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages)); | ||||
| 		if (!this.messagesList.length) { | ||||
| 			this.loadMore(); | ||||
| 		} | ||||
|  | ||||
| 		this.refreshUI(); | ||||
|  | ||||
| 		// subscribe to events | ||||
| 		this.eventBus.on("new", this.handleWSNew); | ||||
| 		this.eventBus.on("update", this.handleWSUpdate); | ||||
| 		this.eventBus.on("delete", this.handleWSDelete); | ||||
| 		this.eventBus.on("truncate", this.handleWSTruncate); | ||||
| 	}, | ||||
|  | ||||
| 	unmounted() { | ||||
| 		// unsubscribe from events | ||||
| 		this.eventBus.off("new", this.handleWSNew); | ||||
| 		this.eventBus.off("update", this.handleWSUpdate); | ||||
| 		this.eventBus.off("delete", this.handleWSDelete); | ||||
| 		this.eventBus.off("truncate", this.handleWSTruncate); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		loadMessage() { | ||||
| 			this.message = false | ||||
| 			const uri = this.resolve('/api/v1/message/' + this.$route.params.id) | ||||
| 			this.get(uri, false, (response) => { | ||||
| 				this.errorMessage = false | ||||
| 				const d = response.data | ||||
| 			this.message = false; | ||||
| 			const uri = this.resolve("/api/v1/message/" + this.$route.params.id); | ||||
| 			this.get( | ||||
| 				uri, | ||||
| 				false, | ||||
| 				(response) => { | ||||
| 					this.errorMessage = false; | ||||
| 					const d = response.data; | ||||
|  | ||||
| 				// update read status in case websockets is not working | ||||
| 				this.handleWSUpdate({ 'ID': d.ID, Read: true }) | ||||
| 					// update read status in case websockets is not working | ||||
| 					this.handleWSUpdate({ ID: d.ID, Read: true }); | ||||
|  | ||||
| 				// replace inline images embedded as inline attachments | ||||
| 				if (d.HTML && d.Inline) { | ||||
| 					for (let i in d.Inline) { | ||||
| 						let a = d.Inline[i] | ||||
| 						if (a.ContentID != '') { | ||||
| 							d.HTML = d.HTML.replace( | ||||
| 								new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'), | ||||
| 								'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' | ||||
| 							) | ||||
| 						} | ||||
| 						if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) { | ||||
| 							// some old email clients use the filename | ||||
| 							d.HTML = d.HTML.replace( | ||||
| 								new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'), | ||||
| 								'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' | ||||
| 							) | ||||
| 					// replace inline images embedded as inline attachments | ||||
| 					if (d.HTML && d.Inline) { | ||||
| 						for (const i in d.Inline) { | ||||
| 							const a = d.Inline[i]; | ||||
| 							if (a.ContentID !== "") { | ||||
| 								d.HTML = d.HTML.replace( | ||||
| 									new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"), | ||||
| 									"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3", | ||||
| 								); | ||||
| 							} | ||||
| 							if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) { | ||||
| 								// some old email clients use the filename | ||||
| 								d.HTML = d.HTML.replace( | ||||
| 									new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"), | ||||
| 									"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3", | ||||
| 								); | ||||
| 							} | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				// replace inline images embedded as regular attachments | ||||
| 				if (d.HTML && d.Attachments) { | ||||
| 					for (let i in d.Attachments) { | ||||
| 						let a = d.Attachments[i] | ||||
| 						if (a.ContentID != '') { | ||||
| 							d.HTML = d.HTML.replace( | ||||
| 								new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'), | ||||
| 								'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' | ||||
| 							) | ||||
| 						} | ||||
| 						if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) { | ||||
| 							// some old email clients use the filename | ||||
| 							d.HTML = d.HTML.replace( | ||||
| 								new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'), | ||||
| 								'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' | ||||
| 							) | ||||
| 					// replace inline images embedded as regular attachments | ||||
| 					if (d.HTML && d.Attachments) { | ||||
| 						for (const i in d.Attachments) { | ||||
| 							const a = d.Attachments[i]; | ||||
| 							if (a.ContentID !== "") { | ||||
| 								d.HTML = d.HTML.replace( | ||||
| 									new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"), | ||||
| 									"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3", | ||||
| 								); | ||||
| 							} | ||||
| 							if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) { | ||||
| 								// some old email clients use the filename | ||||
| 								d.HTML = d.HTML.replace( | ||||
| 									new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"), | ||||
| 									"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3", | ||||
| 								); | ||||
| 							} | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				this.message = d | ||||
| 					this.message = d; | ||||
|  | ||||
| 				this.$nextTick(() => { | ||||
| 					this.scrollSidebarToCurrent() | ||||
| 				}) | ||||
| 			}, | ||||
| 					this.$nextTick(() => { | ||||
| 						this.scrollSidebarToCurrent(); | ||||
| 					}); | ||||
| 				}, | ||||
| 				(error) => { | ||||
| 					this.errorMessage = true | ||||
| 					this.errorMessage = true; | ||||
| 					if (error.response && error.response.data) { | ||||
| 						if (error.response.data.Error) { | ||||
| 							this.errorMessage = error.response.data.Error | ||||
| 							this.errorMessage = error.response.data.Error; | ||||
| 						} else { | ||||
| 							this.errorMessage = error.response.data | ||||
| 							this.errorMessage = error.response.data; | ||||
| 						} | ||||
| 					} else if (error.request) { | ||||
| 						// The request was made but no response was received | ||||
| 						this.errorMessage = 'Error sending data to the server. Please refresh the page.' | ||||
| 						this.errorMessage = "Error sending data to the server. Please refresh the page."; | ||||
| 					} else { | ||||
| 						// Something happened in setting up the request that triggered an Error | ||||
| 						this.errorMessage = error.message | ||||
| 						this.errorMessage = error.message; | ||||
| 					} | ||||
| 				}) | ||||
| 				}, | ||||
| 			); | ||||
| 		}, | ||||
|  | ||||
| 		// UI refresh ticker to adjust relative times | ||||
| 		refreshUI() { | ||||
| 			window.setTimeout(() => { | ||||
| 				this.$forceUpdate() | ||||
| 				this.refreshUI() | ||||
| 			}, 30000) | ||||
| 				this.$forceUpdate(); | ||||
| 				this.refreshUI(); | ||||
| 			}, 30000); | ||||
| 		}, | ||||
|  | ||||
| 		// handler for websocket new messages | ||||
| 		handleWSNew(data) { | ||||
| 			// do not add when searching or >= 100 new messages have been received | ||||
| 			if (this.mailbox.searching || this.liveLoaded >= 100) { | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			this.liveLoaded++ | ||||
| 			this.messagesList.unshift(data) | ||||
| 			this.liveLoaded++; | ||||
| 			this.messagesList.unshift(data); | ||||
| 		}, | ||||
|  | ||||
| 		// handler for websocket message updates | ||||
| 		handleWSUpdate(data) { | ||||
| 			for (let x = 0; x < this.messagesList.length; x++) { | ||||
| 				if (this.messagesList[x].ID == data.ID) { | ||||
| 				if (this.messagesList[x].ID === data.ID) { | ||||
| 					// update message | ||||
| 					this.messagesList[x] = { ...this.messagesList[x], ...data } | ||||
| 					return | ||||
| 					this.messagesList[x] = { ...this.messagesList[x], ...data }; | ||||
| 					return; | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| @@ -240,10 +243,10 @@ export default { | ||||
| 		// handler for websocket message deletion | ||||
| 		handleWSDelete(data) { | ||||
| 			for (let x = 0; x < this.messagesList.length; x++) { | ||||
| 				if (this.messagesList[x].ID == data.ID) { | ||||
| 				if (this.messagesList[x].ID === data.ID) { | ||||
| 					// remove message from the list | ||||
| 					this.messagesList.splice(x, 1) | ||||
| 					return | ||||
| 					this.messagesList.splice(x, 1); | ||||
| 					return; | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| @@ -251,277 +254,299 @@ export default { | ||||
| 		// handler for websocket message truncation | ||||
| 		handleWSTruncate() { | ||||
| 			// all messages gone, go to inbox | ||||
| 			this.$router.push('/') | ||||
| 			this.$router.push("/"); | ||||
| 		}, | ||||
|  | ||||
| 		// return whether the sidebar is visible | ||||
| 		sidebarVisible() { | ||||
| 			return this.$refs.MessageList.offsetParent != null | ||||
| 			return this.$refs.MessageList.offsetParent !== null; | ||||
| 		}, | ||||
|  | ||||
| 		// scroll sidenav to current message if found | ||||
| 		scrollSidebarToCurrent() { | ||||
| 			const cont = document.getElementById('MessageList') | ||||
| 			const cont = document.getElementById("MessageList"); | ||||
| 			if (!cont) { | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
| 			const c = cont.querySelector('.router-link-active') | ||||
| 			const c = cont.querySelector(".router-link-active"); | ||||
| 			if (c) { | ||||
| 				const outer = cont.getBoundingClientRect() | ||||
| 				const li = c.getBoundingClientRect() | ||||
| 				const outer = cont.getBoundingClientRect(); | ||||
| 				const li = c.getBoundingClientRect(); | ||||
| 				if (outer.top > li.top || outer.bottom < li.bottom) { | ||||
| 					c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }) | ||||
| 					c.scrollIntoView({ | ||||
| 						behavior: "smooth", | ||||
| 						block: "center", | ||||
| 						inline: "nearest", | ||||
| 					}); | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		scrollHandler(e) { | ||||
| 			if (!this.canLoadMore || this.scrollLoading) { | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const { scrollTop, offsetHeight, scrollHeight } = e.target | ||||
| 			if ((scrollTop + offsetHeight + 150) >= scrollHeight) { | ||||
| 				this.loadMore() | ||||
| 			const { scrollTop, offsetHeight, scrollHeight } = e.target; | ||||
| 			if (scrollTop + offsetHeight + 150 >= scrollHeight) { | ||||
| 				this.loadMore(); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		loadMore() { | ||||
| 			if (this.messagesList.length) { | ||||
| 				// get last created timestamp | ||||
| 				const oldest = this.messagesList[this.messagesList.length - 1].Created | ||||
| 				// if set append `before=<ts>`  | ||||
| 				this.apiSideNavParams.set('before', oldest) | ||||
| 				const oldest = this.messagesList[this.messagesList.length - 1].Created; | ||||
| 				// if set append `before=<ts>` | ||||
| 				this.apiSideNavParams.set("before", oldest); | ||||
| 			} | ||||
|  | ||||
| 			this.scrollLoading = true | ||||
| 			this.scrollLoading = true; | ||||
|  | ||||
| 			this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => { | ||||
| 				if (response.data.messages.length) { | ||||
| 					this.messagesList.push(...response.data.messages) | ||||
| 				} else { | ||||
| 					this.canLoadMore = false | ||||
| 				} | ||||
| 				this.$nextTick(() => { | ||||
| 					this.scrollLoading = false | ||||
| 				}) | ||||
| 			}, null, true) | ||||
| 			this.get( | ||||
| 				this.apiSideNavURI, | ||||
| 				this.apiSideNavParams, | ||||
| 				(response) => { | ||||
| 					if (response.data.messages.length) { | ||||
| 						this.messagesList.push(...response.data.messages); | ||||
| 					} else { | ||||
| 						this.canLoadMore = false; | ||||
| 					} | ||||
| 					this.$nextTick(() => { | ||||
| 						this.scrollLoading = false; | ||||
| 					}); | ||||
| 				}, | ||||
| 				null, | ||||
| 				true, | ||||
| 			); | ||||
| 		}, | ||||
|  | ||||
| 		initLoadMoreAPIParams() { | ||||
| 			let apiURI = this.resolve(`/api/v1/messages`) | ||||
| 			let p = {} | ||||
| 			let apiURI = this.resolve(`/api/v1/messages`); | ||||
| 			const p = {}; | ||||
|  | ||||
| 			if (mailbox.searching) { | ||||
| 				apiURI = this.resolve(`/api/v1/search`) | ||||
| 				p.query = mailbox.searching | ||||
| 				apiURI = this.resolve(`/api/v1/search`); | ||||
| 				p.query = mailbox.searching; | ||||
| 			} | ||||
|  | ||||
| 			if (pagination.limit != pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString() | ||||
| 			if (pagination.limit !== pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString(); | ||||
| 			} | ||||
|  | ||||
| 			this.apiSideNavURI = apiURI | ||||
| 			this.apiSideNavURI = apiURI; | ||||
|  | ||||
| 			this.apiSideNavParams = new URLSearchParams(p) | ||||
| 			this.apiSideNavParams = new URLSearchParams(p); | ||||
| 		}, | ||||
|  | ||||
| 		getRelativeCreated(message) { | ||||
| 			const d = new Date(message.Created) | ||||
| 			return dayjs(d).fromNow() | ||||
| 			const d = new Date(message.Created); | ||||
| 			return dayjs(d).fromNow(); | ||||
| 		}, | ||||
|  | ||||
| 		getPrimaryEmailTo(message) { | ||||
| 			for (let i in message.To) { | ||||
| 				return message.To[i].Address | ||||
| 			if (message.To && message.To.length > 0) { | ||||
| 				return message.To[0].Address; | ||||
| 			} | ||||
|  | ||||
| 			return '[ Undisclosed recipients ]' | ||||
| 			return "[ Undisclosed recipients ]"; | ||||
| 		}, | ||||
|  | ||||
| 		isActive(id) { | ||||
| 			return this.message.ID == id | ||||
| 			return this.message.ID === id; | ||||
| 		}, | ||||
|  | ||||
| 		toTagUrl(t) { | ||||
| 			if (t.match(/ /)) { | ||||
| 				t = `"${t}"` | ||||
| 				t = `"${t}"`; | ||||
| 			} | ||||
| 			const p = { | ||||
| 				q: 'tag:' + t | ||||
| 				q: "tag:" + t, | ||||
| 			}; | ||||
| 			if (pagination.limit !== pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString(); | ||||
| 			} | ||||
| 			if (pagination.limit != pagination.defaultLimit) { | ||||
| 				p.limit = pagination.limit.toString() | ||||
| 			} | ||||
| 			const params = new URLSearchParams(p) | ||||
| 			return '/search?' + params.toString() | ||||
| 			const params = new URLSearchParams(p); | ||||
| 			return "/search?" + params.toString(); | ||||
| 		}, | ||||
|  | ||||
| 		downloadMessageBody(str, ext) { | ||||
| 			const dl = document.createElement('a') | ||||
| 			dl.href = "data:text/plain," + encodeURIComponent(str) | ||||
| 			dl.target = '_blank' | ||||
| 			dl.download = this.message.ID + '.' + ext | ||||
| 			dl.click() | ||||
| 			const dl = document.createElement("a"); | ||||
| 			dl.href = "data:text/plain," + encodeURIComponent(str); | ||||
| 			dl.target = "_blank"; | ||||
| 			dl.download = this.message.ID + "." + ext; | ||||
| 			dl.click(); | ||||
| 		}, | ||||
|  | ||||
| 		screenshotMessageHTML() { | ||||
| 			this.$refs.ScreenshotRef.initScreenshot() | ||||
| 			this.$refs.ScreenshotRef.initScreenshot(); | ||||
| 		}, | ||||
|  | ||||
| 		// toggle current message read status | ||||
| 		toggleRead() { | ||||
| 			if (!this.message) { | ||||
| 				return false | ||||
| 				return false; | ||||
| 			} | ||||
| 			const read = !this.isRead | ||||
| 			const read = !this.isRead; | ||||
|  | ||||
| 			const ids = [this.message.ID] | ||||
| 			const uri = this.resolve('/api/v1/messages') | ||||
| 			this.put(uri, { 'Read': read, 'IDs': ids }, () => { | ||||
| 			const ids = [this.message.ID]; | ||||
| 			const uri = this.resolve("/api/v1/messages"); | ||||
| 			this.put(uri, { Read: read, IDs: ids }, () => { | ||||
| 				if (!this.sidebarVisible()) { | ||||
| 					return this.goBack() | ||||
| 					return this.goBack(); | ||||
| 				} | ||||
|  | ||||
| 				// manually update read status in case websockets is not working | ||||
| 				this.handleWSUpdate({ 'ID': this.message.ID, Read: read }) | ||||
| 			}) | ||||
| 				this.handleWSUpdate({ ID: this.message.ID, Read: read }); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		deleteMessage() { | ||||
| 			const ids = [this.message.ID] | ||||
| 			const uri = this.resolve('/api/v1/messages') | ||||
| 			const ids = [this.message.ID]; | ||||
| 			const uri = this.resolve("/api/v1/messages"); | ||||
| 			// calculate next ID before deletion to prevent WS race | ||||
| 			const goToID = this.nextID ? this.nextID : this.previousID | ||||
| 			const goToID = this.nextID ? this.nextID : this.previousID; | ||||
|  | ||||
| 			this.delete(uri, { 'IDs': ids }, () => { | ||||
| 			this.delete(uri, { IDs: ids }, () => { | ||||
| 				if (!this.sidebarVisible()) { | ||||
| 					return this.goBack() | ||||
| 					return this.goBack(); | ||||
| 				} | ||||
| 				if (goToID) { | ||||
| 					return this.$router.push('/view/' + goToID) | ||||
| 					return this.$router.push("/view/" + goToID); | ||||
| 				} | ||||
|  | ||||
| 				return this.goBack() | ||||
| 			}) | ||||
| 				return this.goBack(); | ||||
| 			}); | ||||
| 		}, | ||||
|  | ||||
| 		// return to mailbox or search based on origin | ||||
| 		goBack() { | ||||
| 			mailbox.lastMessage = this.$route.params.id | ||||
| 			mailbox.lastMessage = this.$route.params.id; | ||||
|  | ||||
| 			if (mailbox.searching) { | ||||
| 				const p = { | ||||
| 					q: mailbox.searching | ||||
| 				} | ||||
| 					q: mailbox.searching, | ||||
| 				}; | ||||
| 				if (pagination.start > 0) { | ||||
| 					p.start = pagination.start.toString() | ||||
| 					p.start = pagination.start.toString(); | ||||
| 				} | ||||
| 				if (pagination.limit != pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString() | ||||
| 				if (pagination.limit !== pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString(); | ||||
| 				} | ||||
| 				this.$router.push('/search?' + new URLSearchParams(p).toString()) | ||||
| 				this.$router.push("/search?" + new URLSearchParams(p).toString()); | ||||
| 			} else { | ||||
| 				const p = {} | ||||
| 				const p = {}; | ||||
| 				if (pagination.start > 0) { | ||||
| 					p.start = pagination.start.toString() | ||||
| 					p.start = pagination.start.toString(); | ||||
| 				} | ||||
| 				if (pagination.limit != pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString() | ||||
| 				if (pagination.limit !== pagination.defaultLimit) { | ||||
| 					p.limit = pagination.limit.toString(); | ||||
| 				} | ||||
| 				this.$router.push('/?' + new URLSearchParams(p).toString()) | ||||
| 				this.$router.push("/?" + new URLSearchParams(p).toString()); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		reloadWindow() { | ||||
| 			location.reload() | ||||
| 			location.reload(); | ||||
| 		}, | ||||
|  | ||||
| 		initReleaseModal() { | ||||
| 			this.modal('ReleaseModal').show() | ||||
| 			this.modal("ReleaseModal").show(); | ||||
| 			window.setTimeout(() => { | ||||
| 				// delay to allow elements to load / focus | ||||
| 				this.$refs.ReleaseRef.initTags() | ||||
| 				document.querySelector('#ReleaseModal input[role="combobox"]').focus() | ||||
| 			}, 500) | ||||
| 				this.$refs.ReleaseRef.initTags(); | ||||
| 				document.querySelector('#ReleaseModal input[role="combobox"]').focus(); | ||||
| 			}, 500); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none"> | ||||
| 		<div class="d-none d-xl-block col-xl-3 col-auto pe-0"> | ||||
| 			<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit" /> | ||||
| 				<span class="ms-2 d-none d-sm-inline">Mailpit</span> | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
| 		<div class="col col-xl-5" v-if="!errorMessage"> | ||||
| 			<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages"> | ||||
| 		<div v-if="!errorMessage" class="col col-xl-5"> | ||||
| 			<button class="btn btn-outline-light me-3 d-xl-none" title="Return to messages" @click="goBack()"> | ||||
| 				<i class="bi bi-arrow-return-left"></i> | ||||
| 				<span class="ms-2 d-none d-lg-inline">Back</span> | ||||
| 			</button> | ||||
| 			<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()"> | ||||
| 			<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" @click="toggleRead()"> | ||||
| 				<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i> | ||||
| 				<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span> | ||||
| 			</button> | ||||
| 			<button class="btn btn-outline-light me-1 me-sm-2" title="Release message" | ||||
| 			<button | ||||
| 				v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled" | ||||
| 				v-on:click="initReleaseModal()"> | ||||
| 				<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span> | ||||
| 				class="btn btn-outline-light me-1 me-sm-2" | ||||
| 				title="Release message" | ||||
| 				@click="initReleaseModal()" | ||||
| 			> | ||||
| 				<i class="bi bi-send"></i> | ||||
| 				<span class="d-none d-md-inline">Release</span> | ||||
| 			</button> | ||||
| 			<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()"> | ||||
| 				<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span> | ||||
| 			<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" @click="deleteMessage()"> | ||||
| 				<i class="bi bi-trash-fill"></i> | ||||
| 				<span class="d-none d-md-inline">Delete</span> | ||||
| 			</button> | ||||
| 		</div> | ||||
| 		<div class="col-auto col-lg-4 col-xl-4 text-end" v-if="!errorMessage"> | ||||
| 			<div class="dropdown d-inline-block" id="DownloadBtn"> | ||||
| 				<button type="button" class="btn btn-outline-light dropdown-toggle" data-bs-toggle="dropdown" | ||||
| 					aria-expanded="false"> | ||||
| 		<div v-if="!errorMessage" class="col-auto col-lg-4 col-xl-4 text-end"> | ||||
| 			<div id="DownloadBtn" class="dropdown d-inline-block"> | ||||
| 				<button | ||||
| 					type="button" | ||||
| 					class="btn btn-outline-light dropdown-toggle" | ||||
| 					data-bs-toggle="dropdown" | ||||
| 					aria-expanded="false" | ||||
| 				> | ||||
| 					<i class="bi bi-file-arrow-down-fill"></i> | ||||
| 					<span class="d-none d-md-inline ms-1">Download</span> | ||||
| 				</button> | ||||
| 				<ul class="dropdown-menu dropdown-menu-end"> | ||||
| 					<li> | ||||
| 						<a :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" class="dropdown-item" | ||||
| 							title="Message source including headers, body and attachments"> | ||||
| 						<a | ||||
| 							:href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" | ||||
| 							class="dropdown-item" | ||||
| 							title="Message source including headers, body and attachments" | ||||
| 						> | ||||
| 							Raw message | ||||
| 						</a> | ||||
| 					</li> | ||||
| 					<li v-if="message.HTML"> | ||||
| 						<button v-on:click="downloadMessageBody(message.HTML, 'html')" class="dropdown-item"> | ||||
| 						<button class="dropdown-item" @click="downloadMessageBody(message.HTML, 'html')"> | ||||
| 							HTML body | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li v-if="message.HTML"> | ||||
| 						<button class="dropdown-item" @click="screenshotMessageHTML()"> | ||||
| 							HTML screenshot | ||||
| 						</button> | ||||
| 						<button class="dropdown-item" @click="screenshotMessageHTML()">HTML screenshot</button> | ||||
| 					</li> | ||||
| 					<li v-if="message.Text"> | ||||
| 						<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item"> | ||||
| 						<button class="dropdown-item" @click="downloadMessageBody(message.Text, 'txt')"> | ||||
| 							Text body | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<template v-if="message.Attachments && message.Attachments.length"> | ||||
| 						<li> | ||||
| 							<hr class="dropdown-divider"> | ||||
| 							<hr class="dropdown-divider" /> | ||||
| 						</li> | ||||
| 						<li> | ||||
| 							<h6 class="dropdown-header"> | ||||
| 								Attachments | ||||
| 							</h6> | ||||
| 							<h6 class="dropdown-header">Attachments</h6> | ||||
| 						</li> | ||||
| 						<li v-for="part in message.Attachments"> | ||||
| 							<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID" | ||||
| 								class="row m-0 dropdown-item d-flex" target="_blank" | ||||
| 								:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px"> | ||||
| 						<li v-for="part in message.Attachments" :key="part.PartID"> | ||||
| 							<RouterLink | ||||
| 								:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID" | ||||
| 								class="row m-0 dropdown-item d-flex" | ||||
| 								target="_blank" | ||||
| 								:title="part.FileName !== '' ? part.FileName : '[ unknown ]'" | ||||
| 								style="min-width: 350px" | ||||
| 							> | ||||
| 								<div class="col-auto p-0 pe-1"> | ||||
| 									<i class="bi" :class="attachmentIcon(part)"></i> | ||||
| 								</div> | ||||
| 								<div class="col text-truncate p-0 pe-1"> | ||||
| 									{{ part.FileName != '' ? part.FileName : '[ unknown ]' }} | ||||
| 									{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }} | ||||
| 								</div> | ||||
| 								<div class="col-auto text-muted small p-0"> | ||||
| 									{{ getFileSize(part.Size) }} | ||||
| @@ -531,22 +556,24 @@ export default { | ||||
| 					</template> | ||||
| 					<template v-if="message.Inline && message.Inline.length"> | ||||
| 						<li> | ||||
| 							<hr class="dropdown-divider"> | ||||
| 							<hr class="dropdown-divider" /> | ||||
| 						</li> | ||||
| 						<li> | ||||
| 							<h6 class="dropdown-header"> | ||||
| 								Inline image<span v-if="message.Inline.length > 1">s</span> | ||||
| 							</h6> | ||||
| 							<h6 class="dropdown-header">Inline image<span v-if="message.Inline.length > 1">s</span></h6> | ||||
| 						</li> | ||||
| 						<li v-for="part in message.Inline"> | ||||
| 							<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID" | ||||
| 								class="row m-0 dropdown-item d-flex" target="_blank" | ||||
| 								:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px"> | ||||
| 						<li v-for="part in message.Inline" :key="part.PartID"> | ||||
| 							<RouterLink | ||||
| 								:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID" | ||||
| 								class="row m-0 dropdown-item d-flex" | ||||
| 								target="_blank" | ||||
| 								:title="part.FileName !== '' ? part.FileName : '[ unknown ]'" | ||||
| 								style="min-width: 350px" | ||||
| 							> | ||||
| 								<div class="col-auto p-0 pe-1"> | ||||
| 									<i class="bi" :class="attachmentIcon(part)"></i> | ||||
| 								</div> | ||||
| 								<div class="col text-truncate p-0 pe-1"> | ||||
| 									{{ part.FileName != '' ? part.FileName : '[ unknown ]' }} | ||||
| 									{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }} | ||||
| 								</div> | ||||
| 								<div class="col-auto text-muted small p-0"> | ||||
| 									{{ getFileSize(part.Size) }} | ||||
| @@ -557,8 +584,12 @@ export default { | ||||
| 				</ul> | ||||
| 			</div> | ||||
|  | ||||
| 			<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1" | ||||
| 				:class="previousID ? '' : 'disabled'" title="View previous message"> | ||||
| 			<RouterLink | ||||
| 				:to="'/view/' + previousID" | ||||
| 				class="btn btn-outline-light ms-1 ms-sm-2 me-1" | ||||
| 				:class="previousID ? '' : 'disabled'" | ||||
| 				title="View previous message" | ||||
| 			> | ||||
| 				<i class="bi bi-caret-left-fill"></i> | ||||
| 			</RouterLink> | ||||
| 			<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'"> | ||||
| @@ -567,69 +598,89 @@ export default { | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="row flex-fill" style="min-height:0"> | ||||
| 	<div class="row flex-fill" style="min-height: 0"> | ||||
| 		<div class="d-none d-xl-flex col-xl-3 h-100 flex-column"> | ||||
| 			<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label"> | ||||
| 			<div v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100"> | ||||
| 				<div class="text-truncate fw-normal" style="line-height: 1rem"> | ||||
| 					{{ mailbox.uiConfig.Label }} | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''"> | ||||
| 				<button @click="goBack()" class="list-group-item list-group-item-action"> | ||||
| 				<button class="list-group-item list-group-item-action" @click="goBack()"> | ||||
| 					<i class="bi bi-arrow-return-left me-1"></i> | ||||
| 					<span class="ms-1"> | ||||
| 						Return to | ||||
| 						<template v-if="mailbox.searching">search</template> | ||||
| 						<template v-else>inbox</template> | ||||
| 					</span> | ||||
| 					<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages" | ||||
| 						v-if="mailbox.unread && !errorMessage"> | ||||
| 					<span | ||||
| 						v-if="mailbox.unread && !errorMessage" | ||||
| 						class="badge rounded-pill ms-1 float-end text-bg-secondary" | ||||
| 						title="Unread messages" | ||||
| 					> | ||||
| 						{{ formatNumber(mailbox.unread) }} | ||||
| 					</span> | ||||
| 				</button> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList" | ||||
| 				@scroll="scrollHandler"> | ||||
| 			<div | ||||
| 				id="MessageList" | ||||
| 				ref="MessageList" | ||||
| 				class="flex-grow-1 overflow-y-auto px-1 me-n1" | ||||
| 				@scroll="scrollHandler" | ||||
| 			> | ||||
| 				<button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()"> | ||||
| 					Reload to see newer messages | ||||
| 				</button> | ||||
| 				<template v-if="messagesList && messagesList.length"> | ||||
| 					<div class="list-group"> | ||||
| 						<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID" | ||||
| 							:id="message.ID" | ||||
| 						<RouterLink | ||||
| 							v-for="summary in messagesList" | ||||
| 							:id="summary.ID" | ||||
| 							:key="'summary_' + summary.ID" | ||||
| 							:to="'/view/' + summary.ID" | ||||
| 							class="row gx-1 message d-flex small list-group-item list-group-item-action message" | ||||
| 							:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''"> | ||||
| 							:class="[summary.Read ? 'read' : '', isActive(summary.ID) ? 'active' : '']" | ||||
| 						> | ||||
| 							<div class="col overflow-x-hidden"> | ||||
| 								<div class="text-truncate privacy small"> | ||||
| 									<strong v-if="message.From" :title="'From: ' + message.From.Address"> | ||||
| 										{{ message.From.Name ? message.From.Name : message.From.Address }} | ||||
| 									<strong v-if="summary.From" :title="'From: ' + summary.From.Address"> | ||||
| 										{{ summary.From.Name ? summary.From.Name : summary.From.Address }} | ||||
| 									</strong> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="col-auto small"> | ||||
| 								<i class="bi bi-paperclip h6" v-if="message.Attachments"></i> | ||||
| 								{{ getRelativeCreated(message) }} | ||||
| 								<i v-if="summary.Attachments" class="bi bi-paperclip h6"></i> | ||||
| 								{{ getRelativeCreated(summary) }} | ||||
| 							</div> | ||||
| 							<div class="col-12 overflow-x-hidden"> | ||||
| 								<div class="text-truncate privacy small"> | ||||
| 									To: {{ getPrimaryEmailTo(message) }} | ||||
| 									<span v-if="message.To && message.To.length > 1"> | ||||
| 										[+{{ message.To.length - 1 }}] | ||||
| 									To: {{ getPrimaryEmailTo(summary) }} | ||||
| 									<span v-if="summary.To && summary.To.length > 1"> | ||||
| 										[+{{ summary.To.length - 1 }}] | ||||
| 									</span> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div class="col-12 overflow-x-hidden mt-1"> | ||||
| 								<div class="text-truncates small"> | ||||
| 									<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b> | ||||
| 									<b>{{ summary.Subject !== "" ? summary.Subject : "[ no subject ]" }}</b> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<div v-if="message.Tags.length" class="col-12"> | ||||
| 								<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)" | ||||
| 									v-on:click="pagination.start = 0" | ||||
| 									:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }" | ||||
| 									:title="'Filter messages tagged with ' + t"> | ||||
| 							<div v-if="summary.Tags.length" class="col-12"> | ||||
| 								<RouterLink | ||||
| 									v-for="t in summary.Tags" | ||||
| 									:key="t" | ||||
| 									class="badge me-1" | ||||
| 									:to="toTagUrl(t)" | ||||
| 									:style=" | ||||
| 										mailbox.showTagColors | ||||
| 											? { backgroundColor: colorHash(t) } | ||||
| 											: { backgroundColor: '#6c757d' } | ||||
| 									" | ||||
| 									:title="'Filter messages tagged with ' + t" | ||||
| 									@click="pagination.start = 0" | ||||
| 								> | ||||
| 									{{ t }} | ||||
| 								</RouterLink> | ||||
| 							</div> | ||||
| @@ -642,7 +693,7 @@ export default { | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0"> | ||||
| 			<div class="mh-100" style="overflow-y: auto;" id="message-page"> | ||||
| 			<div id="message-page" class="mh-100" style="overflow-y: auto"> | ||||
| 				<template v-if="errorMessage"> | ||||
| 					<h3 class="text-center my-3"> | ||||
| 						{{ errorMessage }} | ||||
| @@ -655,7 +706,11 @@ export default { | ||||
|  | ||||
| 	<AboutMailpit modals /> | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| 	<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message" | ||||
| 		@delete="deleteMessage" /> | ||||
| 	<Release | ||||
| 		v-if="mailbox.uiConfig.MessageRelay && message" | ||||
| 		ref="ReleaseRef" | ||||
| 		:message="message" | ||||
| 		@delete="deleteMessage" | ||||
| 	/> | ||||
| 	<Screenshot v-if="message" ref="ScreenshotRef" :message="message" /> | ||||
| </template> | ||||
|   | ||||
| @@ -1,21 +1,21 @@ | ||||
| <script> | ||||
| import AboutMailpit from '../components/AboutMailpit.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import About from "../components/AppAbout.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		AboutMailpit, | ||||
| 		About, | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| 	mixins: [CommonMixins], | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white"> | ||||
| 		<div class="d-block text-center"> | ||||
| 			<RouterLink to="/" class="text-white"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width:80%; width: 100px;"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width: 80%; width: 100px" /> | ||||
| 				<p class="h2 my-3">Page not found</p> | ||||
|  | ||||
| 				<p>Click here to continue</p> | ||||
| @@ -23,7 +23,7 @@ export default { | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="d-none"> | ||||
| 			<AboutMailpit /> | ||||
| 			<About /> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
|   | ||||
| @@ -1,24 +1,19 @@ | ||||
| <script> | ||||
| import AboutMailpit from '../components/AboutMailpit.vue' | ||||
| import AjaxLoader from '../components/AjaxLoader.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import ListMessages from '../components/ListMessages.vue' | ||||
| import MessagesMixins from '../mixins/MessagesMixins' | ||||
| import NavSearch from '../components/NavSearch.vue' | ||||
| import NavTags from '../components/NavTags.vue' | ||||
| import Pagination from '../components/Pagination.vue' | ||||
| import SearchForm from '../components/SearchForm.vue' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
| import About from "../components/AppAbout.vue"; | ||||
| import AjaxLoader from "../components/AjaxLoader.vue"; | ||||
| import CommonMixins from "../mixins/CommonMixins"; | ||||
| import ListMessages from "../components/ListMessages.vue"; | ||||
| import MessagesMixins from "../mixins/MessagesMixins"; | ||||
| import NavSearch from "../components/NavSearch.vue"; | ||||
| import NavTags from "../components/NavTags.vue"; | ||||
| import Pagination from "../components/NavPagination.vue"; | ||||
| import SearchForm from "../components/SearchForm.vue"; | ||||
| import { mailbox } from "../stores/mailbox"; | ||||
| import { pagination } from "../stores/pagination"; | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins, MessagesMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	components: { | ||||
| 		AboutMailpit, | ||||
| 		About, | ||||
| 		AjaxLoader, | ||||
| 		ListMessages, | ||||
| 		NavSearch, | ||||
| @@ -27,63 +22,68 @@ export default { | ||||
| 		SearchForm, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [CommonMixins, MessagesMixins], | ||||
|  | ||||
| 	// global event bus to handle message status changes | ||||
| 	inject: ["eventBus"], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 			delayedRefresh: false, | ||||
| 		} | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		$route(to, from) { | ||||
| 			this.doSearch() | ||||
| 		} | ||||
| 			this.doSearch(); | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		mailbox.searching = this.getSearch() | ||||
| 		this.doSearch() | ||||
| 		mailbox.searching = this.getSearch(); | ||||
| 		this.doSearch(); | ||||
|  | ||||
| 		// subscribe to events | ||||
| 		this.eventBus.on("update", this.handleWSUpdate) | ||||
| 		this.eventBus.on("delete", this.handleWSDelete) | ||||
| 		this.eventBus.on("truncate", this.handleWSTruncate) | ||||
| 		this.eventBus.on("update", this.handleWSUpdate); | ||||
| 		this.eventBus.on("delete", this.handleWSDelete); | ||||
| 		this.eventBus.on("truncate", this.handleWSTruncate); | ||||
| 	}, | ||||
|  | ||||
| 	unmounted() { | ||||
| 		// unsubscribe from events | ||||
| 		this.eventBus.off("update", this.handleWSUpdate) | ||||
| 		this.eventBus.off("delete", this.handleWSDelete) | ||||
| 		this.eventBus.off("truncate", this.handleWSTruncate) | ||||
| 		this.eventBus.off("update", this.handleWSUpdate); | ||||
| 		this.eventBus.off("delete", this.handleWSDelete); | ||||
| 		this.eventBus.off("truncate", this.handleWSTruncate); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		doSearch() { | ||||
| 			const s = this.getSearch() | ||||
| 			const s = this.getSearch(); | ||||
|  | ||||
| 			if (!s) { | ||||
| 				mailbox.searching = false | ||||
| 				this.$router.push('/') | ||||
| 				return | ||||
| 				mailbox.searching = false; | ||||
| 				this.$router.push("/"); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			mailbox.searching = s | ||||
| 			mailbox.searching = s; | ||||
|  | ||||
| 			this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s) | ||||
| 			if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) { | ||||
| 				this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone) | ||||
| 			this.apiURI = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s); | ||||
| 			if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) { | ||||
| 				this.apiURI += "&tz=" + encodeURIComponent(mailbox.timeZone); | ||||
| 			} | ||||
| 			this.loadMessages() | ||||
| 			this.loadMessages(); | ||||
| 		}, | ||||
|  | ||||
| 		// handler for websocket message updates | ||||
| 		handleWSUpdate(data) { | ||||
| 			for (let x = 0; x < this.mailbox.messages.length; x++) { | ||||
| 				if (this.mailbox.messages[x].ID == data.ID) { | ||||
| 				if (this.mailbox.messages[x].ID === data.ID) { | ||||
| 					// update message | ||||
| 					this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data } | ||||
| 					return | ||||
| 					this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }; | ||||
| 					return; | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| @@ -92,52 +92,57 @@ export default { | ||||
| 		handleWSDelete(data) { | ||||
| 			let removed = 0; | ||||
| 			for (let x = 0; x < this.mailbox.messages.length; x++) { | ||||
| 				if (this.mailbox.messages[x].ID == data.ID) { | ||||
| 				if (this.mailbox.messages[x].ID === data.ID) { | ||||
| 					// remove message from the list | ||||
| 					this.mailbox.messages.splice(x, 1) | ||||
| 					removed++ | ||||
| 					continue | ||||
| 					this.mailbox.messages.splice(x, 1); | ||||
| 					removed++; | ||||
| 					continue; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (!removed || this.delayedRefresh) { | ||||
| 				// nothing changed on this screen, or a refresh is queued, don't refresh | ||||
| 				return | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// delayedRefresh prevents unnecessary reloads when multiple messages are deleted | ||||
| 			this.delayedRefresh = true | ||||
| 			this.delayedRefresh = true; | ||||
|  | ||||
| 			window.setTimeout(() => { | ||||
| 				this.delayedRefresh = false | ||||
| 				this.loadMessages() | ||||
| 			}, 500) | ||||
| 				this.delayedRefresh = false; | ||||
| 				this.loadMessages(); | ||||
| 			}, 500); | ||||
| 		}, | ||||
|  | ||||
| 		// handler for websocket message truncation | ||||
| 		handleWSTruncate() { | ||||
| 			// all messages deleted, go back to inbox | ||||
| 			this.$router.push('/') | ||||
| 			this.$router.push("/"); | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 	}, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none"> | ||||
| 		<div class="col-xl-2 col-md-3 col-auto pe-0"> | ||||
| 			<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit"> | ||||
| 				<img :src="resolve('/mailpit.svg')" alt="Mailpit" /> | ||||
| 				<span class="ms-2 d-none d-sm-inline">Mailpit</span> | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
| 		<div class="col col-md-4k col-lg-5 col-xl-6"> | ||||
| 			<SearchForm @loadMessages="loadMessages" /> | ||||
| 			<SearchForm @load-messages="loadMessages" /> | ||||
| 		</div> | ||||
| 		<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0"> | ||||
| 			<div class="float-start d-md-none"> | ||||
| 				<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas" | ||||
| 					data-bs-target="#offcanvas" aria-controls="offcanvas"> | ||||
| 				<button | ||||
| 					class="btn btn-outline-light me-2" | ||||
| 					type="button" | ||||
| 					data-bs-toggle="offcanvas" | ||||
| 					data-bs-target="#offcanvas" | ||||
| 					aria-controls="offcanvas" | ||||
| 				> | ||||
| 					<i class="bi bi-list"></i> | ||||
| 				</button> | ||||
| 			</div> | ||||
| @@ -145,41 +150,51 @@ export default { | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas" | ||||
| 		aria-labelledby="offcanvasLabel"> | ||||
| 	<div | ||||
| 		id="offcanvas" | ||||
| 		class="offcanvas-md offcanvas-start d-md-none" | ||||
| 		data-bs-scroll="true" | ||||
| 		tabindex="-1" | ||||
| 		aria-labelledby="offcanvasLabel" | ||||
| 	> | ||||
| 		<div class="offcanvas-header"> | ||||
| 			<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5> | ||||
| 			<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas" | ||||
| 				aria-label="Close"></button> | ||||
| 			<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5> | ||||
| 			<button | ||||
| 				type="button" | ||||
| 				class="btn-close" | ||||
| 				data-bs-dismiss="offcanvas" | ||||
| 				data-bs-target="#offcanvas" | ||||
| 				aria-label="Close" | ||||
| 			></button> | ||||
| 		</div> | ||||
| 		<div class="offcanvas-body pb-0"> | ||||
| 			<div class="d-flex flex-column h-100"> | ||||
| 				<div class="flex-grow-1 overflow-y-auto me-n3 pe-3"> | ||||
| 					<NavSearch @loadMessages="loadMessages" /> | ||||
| 					<NavSearch @load-messages="loadMessages" /> | ||||
| 					<NavTags /> | ||||
| 				</div> | ||||
| 				<AboutMailpit /> | ||||
| 				<About /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<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="flex-grow-1 overflow-y-auto me-n3 pe-3"> | ||||
| 				<NavSearch @loadMessages="loadMessages" /> | ||||
| 				<NavSearch @load-messages="loadMessages" /> | ||||
| 				<NavTags /> | ||||
| 			</div> | ||||
| 			<AboutMailpit /> | ||||
| 			<About /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0"> | ||||
| 			<div class="mh-100" style="overflow-y: auto;" id="message-page"> | ||||
| 			<div id="message-page" class="mh-100" style="overflow-y: auto"> | ||||
| 				<ListMessages :loading-messages="loading" /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<NavSearch @loadMessages="loadMessages" modals /> | ||||
| 	<AboutMailpit modals /> | ||||
| 	<NavSearch modals @load-messages="loadMessages" /> | ||||
| 	<About modals /> | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user