mirror of
				https://github.com/axllent/mailpit.git
				synced 2025-10-31 00:07:43 +02:00 
			
		
		
		
	| @@ -18,6 +18,7 @@ import ( | ||||
| 	"github.com/axllent/mailpit/server/apiv1" | ||||
| 	"github.com/axllent/mailpit/server/handlers" | ||||
| 	"github.com/axllent/mailpit/server/websockets" | ||||
| 	"github.com/axllent/mailpit/storage" | ||||
| 	"github.com/axllent/mailpit/utils/logger" | ||||
| 	"github.com/gorilla/mux" | ||||
| ) | ||||
| @@ -223,6 +224,7 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) { | ||||
| // Websocket to broadcast changes | ||||
| func apiWebsocket(w http.ResponseWriter, r *http.Request) { | ||||
| 	websockets.ServeWs(websockets.MessageHub, w, r) | ||||
| 	storage.BroadcastMailboxStats() | ||||
| } | ||||
|  | ||||
| // Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified | ||||
|   | ||||
| @@ -1,12 +1,8 @@ | ||||
| <script> | ||||
| import { RouterLink, RouterView } from 'vue-router' | ||||
| import CommonMixins from './mixins/CommonMixins.js' | ||||
| // import WebsocketMixin from './mixins/WebsocketMixin.js' | ||||
| import Notifications from "./components/Notifications.vue" | ||||
| import CommonMixins from './mixins/CommonMixins' | ||||
| import Notifications from './components/Notifications.vue' | ||||
| import { RouterView } from 'vue-router' | ||||
| import { mailbox } from "./stores/mailbox" | ||||
| // import RepoSelector from "./components/RepoSelector.vue" | ||||
| // import ThemeToggle from "./components/ThemeToggle.vue" | ||||
| // import Loading from './components/Loading.vue' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| @@ -17,11 +13,19 @@ export default { | ||||
|  | ||||
| 	beforeMount() { | ||||
| 		document.title = document.title + ' - ' + location.hostname | ||||
| 		window.baseURL = this.$router.resolve(`/`).href | ||||
| 		mailbox.showTagColors = localStorage.getItem('showTagsColors') == '1' | ||||
|  | ||||
| 		// load global config | ||||
| 		this.get(this.resolve('/api/v1/webui'), false, function (response) { | ||||
| 			mailbox.uiConfig = response.data | ||||
| 		}) | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 	watch: { | ||||
| 		$route(to, from) { | ||||
| 			// hide mobile menu on URL change | ||||
| 			this.hideNav() | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,11 @@ | ||||
| import { createApp } from 'vue' | ||||
| import App from './App.vue' | ||||
| import router from './router' | ||||
| import { createApp } from 'vue' | ||||
|  | ||||
| import "./assets/styles.scss"; | ||||
| import "bootstrap-icons/font/bootstrap-icons.scss"; | ||||
| import "bootstrap"; | ||||
| import './assets/styles.scss' | ||||
| import 'bootstrap-icons/font/bootstrap-icons.scss' | ||||
| import 'bootstrap' | ||||
|  | ||||
| const app = createApp(App) | ||||
|  | ||||
| app.use(router) | ||||
|  | ||||
| app.mount('#app') | ||||
|   | ||||
| @@ -39,7 +39,7 @@ | ||||
| // @import "bootstrap/scss/popover"; | ||||
| // @import "bootstrap/scss/carousel"; | ||||
| @import "bootstrap/scss/spinners"; | ||||
| // @import "bootstrap/scss/offcanvas"; | ||||
| @import "bootstrap/scss/offcanvas"; | ||||
| // @import "bootstrap/scss/popover"; | ||||
| @import "bootstrap/scss/progress"; | ||||
| 
 | ||||
| @@ -1,9 +1,21 @@ | ||||
| // Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92 | ||||
| $font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", | ||||
|     Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; | ||||
| $font-family-sans-serif: | ||||
|     system-ui, | ||||
|     -apple-system, | ||||
|     "Segoe UI", | ||||
|     Roboto, | ||||
|     "Helvetica Neue", | ||||
|     "Noto Sans", | ||||
|     "Liberation Sans", | ||||
|     Arial, | ||||
|     sans-serif, | ||||
|     "Apple Color Emoji", | ||||
|     "Segoe UI Emoji", | ||||
|     "Segoe UI Symbol"; | ||||
|  | ||||
| $link-decoration: none; | ||||
| $primary: #2c3e50; | ||||
| $list-group-disabled-color: #adb5bd; | ||||
| $enable-negative-margins: true; | ||||
| $body-color-dark: #e7eaed; | ||||
| $offcanvas-border-width: 0; | ||||
|   | ||||
| @@ -86,6 +86,13 @@ | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .about-mailpit { | ||||
| 	@include media-breakpoint-down(md) { | ||||
| 		width: var(--bs-offcanvas-width); | ||||
| 		margin-left: -1rem !important; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .message { | ||||
| 	&.read { | ||||
| 		color: $text-muted; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
| import AjaxLoader from './AjaxLoader.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| @@ -9,16 +10,23 @@ export default { | ||||
| 		AjaxLoader | ||||
| 	}, | ||||
|  | ||||
| 	props: { | ||||
| 		modals: { | ||||
| 			type: Boolean, | ||||
| 			default: false, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			theme: 'auto', | ||||
| 			icon: '#circle-half', | ||||
| 			icon: 'circle-half', | ||||
| 			icons: { | ||||
| 				'auto': '#circle-half', | ||||
| 				'light': '#sun-fill', | ||||
| 				'dark': '#moon-stars-fill' | ||||
| 				'auto': 'circle-half', | ||||
| 				'light': 'sun-fill', | ||||
| 				'dark': 'moon-stars-fill' | ||||
| 			}, | ||||
| 			appInfo: {}, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| @@ -29,8 +37,8 @@ export default { | ||||
| 	methods: { | ||||
| 		loadInfo: function () { | ||||
| 			let self = this | ||||
| 			self.get(this.baseURL + 'api/v1/info', false, function (response) { | ||||
| 				self.appInfo = response.data | ||||
| 			self.get(self.resolve('/api/v1/info'), false, function (response) { | ||||
| 				mailbox.appInfo = response.data | ||||
| 				self.modal('AppInfoModal').show() | ||||
| 			}) | ||||
| 		}, | ||||
| @@ -55,9 +63,7 @@ export default { | ||||
| 				return storedTheme | ||||
| 			} | ||||
|  | ||||
| 			return window.matchMedia('(prefers-color-scheme: dark)').matches | ||||
| 				? 'dark' | ||||
| 				: 'light' | ||||
| 			return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' | ||||
| 		}, | ||||
|  | ||||
| 		setTheme: function (theme) { | ||||
| @@ -71,140 +77,163 @@ export default { | ||||
| 			} else { | ||||
| 				document.documentElement.setAttribute('data-bs-theme', theme) | ||||
| 			} | ||||
| 		} | ||||
| 		}, | ||||
|  | ||||
| 		requestNotifications: function () { | ||||
| 			// check if the browser supports notifications | ||||
| 			if (!("Notification" in window)) { | ||||
| 				alert("This browser does not support desktop notification") | ||||
| 			} | ||||
|  | ||||
| 			// we need to ask the user for permission | ||||
| 			else if (Notification.permission !== "denied") { | ||||
| 				let self = this | ||||
| 				Notification.requestPermission().then(function (permission) { | ||||
| 					if (permission === "granted") { | ||||
| 						mailbox.notificationsEnabled = true | ||||
| 					} | ||||
| 				}) | ||||
| 			} | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-lg-2 col-md-3 pe-3 z-3"> | ||||
| 		<button class="text-muted btn btn-sm" v-on:click="loadInfo"> | ||||
| 			<i class="bi bi-info-circle-fill"></i> | ||||
| 			About | ||||
| 		</button> | ||||
|  | ||||
| 		<svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> | ||||
| 			<symbol id="bootstrap" viewBox="0 0 512 408" fill="currentcolor"> | ||||
| 				<path | ||||
| 					d="M106.342 0c-29.214 0-50.827 25.58-49.86 53.32.927 26.647-.278 61.165-8.966 89.31C38.802 170.862 24.07 188.707 0 191v26c24.069 2.293 38.802 20.138 47.516 48.37 8.688 28.145 9.893 62.663 8.965 89.311C55.515 382.42 77.128 408 106.342 408h299.353c29.214 0 50.827-25.58 49.861-53.319-.928-26.648.277-61.166 8.964-89.311 8.715-28.232 23.411-46.077 47.48-48.37v-26c-24.069-2.293-38.765-20.138-47.48-48.37-8.687-28.145-9.892-62.663-8.964-89.31C456.522 25.58 434.909 0 405.695 0H106.342zm236.559 251.102c0 38.197-28.501 61.355-75.798 61.355h-87.202a2 2 0 01-2-2v-213a2 2 0 012-2h86.74c39.439 0 65.322 21.354 65.322 54.138 0 23.008-17.409 43.61-39.594 47.219v1.203c30.196 3.309 50.532 24.212 50.532 53.085zm-84.58-128.125h-45.91v64.814h38.669c29.888 0 46.373-12.03 46.373-33.535 0-20.151-14.174-31.279-39.132-31.279zm-45.91 90.53v71.431h47.605c31.12 0 47.605-12.482 47.605-35.941 0-23.46-16.947-35.49-49.608-35.49h-45.602z" /> | ||||
| 			</symbol> | ||||
| 			<symbol id="check2" viewBox="0 0 16 16" fill="currentcolor"> | ||||
| 				<path | ||||
| 					d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" /> | ||||
| 			</symbol> | ||||
| 			<symbol id="circle-half" viewBox="0 0 16 16" fill="currentcolor"> | ||||
| 				<path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" /> | ||||
| 			</symbol> | ||||
| 			<symbol id="moon-stars-fill" viewBox="0 0 16 16" fill="currentcolor"> | ||||
| 				<path | ||||
| 					d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z" /> | ||||
| 				<path | ||||
| 					d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" /> | ||||
| 			</symbol> | ||||
| 			<symbol id="sun-fill" viewBox="0 0 16 16" fill="currentcolor"> | ||||
| 				<path | ||||
| 					d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" /> | ||||
| 			</symbol> | ||||
| 		</svg> | ||||
| 		<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block"> | ||||
| 			<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false" | ||||
| 				title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme"> | ||||
| 				<svg class="bi my-1 theme-icon-active" width="1em" height="1em"> | ||||
| 					<use :href="icon"></use> | ||||
| 				</svg> | ||||
| 				<span class="visually-hidden" id="bd-theme-text">Toggle theme</span> | ||||
| 	<template v-if="!modals"> | ||||
| 		<div class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit"> | ||||
| 			<button class="text-muted btn btn-sm" v-on:click="loadInfo"> | ||||
| 				<i class="bi bi-info-circle-fill me-1"></i> | ||||
| 				About | ||||
| 			</button> | ||||
|  | ||||
| 			<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block"> | ||||
| 				<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false" | ||||
| 					title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme"> | ||||
| 					<i :class="'bi bi-' + icon + ' my-1'"></i> | ||||
| 					<span class="visually-hidden" id="bd-theme-text">Toggle theme</span> | ||||
| 				</button> | ||||
| 				<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text"> | ||||
| 					<li> | ||||
| 						<button type="button" class="dropdown-item d-flex align-items-center" | ||||
| 							:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')"> | ||||
| 							<i class="bi bi-sun-fill me-2 opacity-50"></i> | ||||
| 							Light | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li> | ||||
| 						<button type="button" class="dropdown-item d-flex align-items-center" | ||||
| 							:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')"> | ||||
| 							<i class="bi bi-moon-stars-fill me-2 opacity-50"></i> | ||||
| 							Dark | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li> | ||||
| 						<button type="button" class="dropdown-item d-flex align-items-center" | ||||
| 							:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')"> | ||||
| 							<i class="bi bi-circle-half me-2 opacity-50"></i> | ||||
| 							Auto | ||||
| 						</button> | ||||
| 					</li> | ||||
| 				</ul> | ||||
| 			</div> | ||||
|  | ||||
| 			<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"> | ||||
| 				<i class="bi bi-bell"></i> | ||||
| 			</button> | ||||
| 			<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text"> | ||||
| 				<li> | ||||
| 					<button type="button" class="dropdown-item d-flex align-items-center" | ||||
| 						:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')"> | ||||
| 						<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em"> | ||||
| 							<use href="#sun-fill"></use> | ||||
| 						</svg> | ||||
| 						Light | ||||
| 					</button> | ||||
| 				</li> | ||||
| 				<li> | ||||
| 					<button type="button" class="dropdown-item d-flex align-items-center" | ||||
| 						:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')"> | ||||
| 						<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em"> | ||||
| 							<use href="#moon-stars-fill"></use> | ||||
| 						</svg> | ||||
| 						Dark | ||||
| 					</button> | ||||
| 				</li> | ||||
| 				<li> | ||||
| 					<button type="button" class="dropdown-item d-flex align-items-center" | ||||
| 						:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')"> | ||||
| 						<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em"> | ||||
| 							<use href="#circle-half"></use> | ||||
| 						</svg> | ||||
| 						Auto | ||||
| 					</button> | ||||
| 				</li> | ||||
| 			</ul> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<!-- Modal --> | ||||
| 	<div class="modal fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true"> | ||||
| 		<div class="modal-dialog"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header" v-if="appInfo"> | ||||
| 					<h5 class="modal-title" id="AppInfoModalLabel"> | ||||
| 						Mailpit | ||||
| 						<code>({{ appInfo.Version }})</code> | ||||
| 					</h5> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<a class="btn btn-warning d-block mb-3" v-if="appInfo.Version != appInfo.LatestVersion" | ||||
| 						:href="'https://github.com/axllent/mailpit/releases/tag/' + appInfo.LatestVersion"> | ||||
| 						A new version of Mailpit ({{ appInfo.LatestVersion }}) is available. | ||||
| 					</a> | ||||
| 	<template v-else> | ||||
| 		<!-- Modals --> | ||||
| 		<div class="modal fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true"> | ||||
| 			<div class="modal-dialog"> | ||||
| 				<div class="modal-content"> | ||||
| 					<div class="modal-header" v-if="mailbox.appInfo"> | ||||
| 						<h5 class="modal-title" id="AppInfoModalLabel"> | ||||
| 							Mailpit | ||||
| 							<code>({{ mailbox.appInfo.Version }})</code> | ||||
| 						</h5> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						<a class="btn btn-warning d-block mb-3" | ||||
| 							v-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion" | ||||
| 							:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion"> | ||||
| 							A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available. | ||||
| 						</a> | ||||
|  | ||||
| 					<div class="row g-3"> | ||||
| 						<div class="col-12"> | ||||
| 							<a class="btn btn-primary w-100" href="api/v1/" target="_blank"> | ||||
| 								<i class="bi bi-braces"></i> | ||||
| 								OpenAPI / Swagger API documentation | ||||
| 							</a> | ||||
| 						</div> | ||||
| 						<div class="col-sm-6"> | ||||
| 							<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://github.com/axllent/mailpit/wiki" target="_blank"> | ||||
| 								Documentation | ||||
| 							</a> | ||||
| 						</div> | ||||
| 						<div class="col-sm-6"> | ||||
| 							<div class="card border-secondary text-center"> | ||||
| 								<div class="card-header">Database size</div> | ||||
| 								<div class="card-body text-secondary"> | ||||
| 									<h5 class="card-title">{{ getFileSize(appInfo.DatabaseSize) }} </h5> | ||||
| 						<div class="row g-3"> | ||||
| 							<div class="col-12"> | ||||
| 								<RouterLink to="/api/v1/" class="btn btn-primary w-100" target="_blank"> | ||||
| 									<i class="bi bi-braces"></i> | ||||
| 									OpenAPI / Swagger API documentation | ||||
| 								</RouterLink> | ||||
| 							</div> | ||||
| 							<div class="col-sm-6"> | ||||
| 								<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://github.com/axllent/mailpit/wiki" | ||||
| 									target="_blank"> | ||||
| 									Documentation | ||||
| 								</a> | ||||
| 							</div> | ||||
| 							<div class="col-6"> | ||||
| 								<div class="card border-secondary text-center"> | ||||
| 									<div class="card-header">Database size</div> | ||||
| 									<div class="card-body text-secondary"> | ||||
| 										<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }} </h5> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 						<div class="col-sm-6"> | ||||
| 							<div class="card border-secondary text-center"> | ||||
| 								<div class="card-header">RAM usage</div> | ||||
| 								<div class="card-body text-secondary"> | ||||
| 									<h5 class="card-title">{{ getFileSize(appInfo.Memory) }} </h5> | ||||
| 							<div class="col-6"> | ||||
| 								<div class="card border-secondary text-center"> | ||||
| 									<div class="card-header">RAM usage</div> | ||||
| 									<div class="card-body text-secondary"> | ||||
| 										<h5 class="card-title">{{ getFileSize(mailbox.appInfo.Memory) }} </h5> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="modal-footer"> | ||||
| 					<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 		<div class="modal fade" id="EnableNotificationsModal" 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> | ||||
| 						<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. | ||||
| 						</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" data-bs-dismiss="modal" | ||||
| 							v-on:click="requestNotifications">Enable notifications</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
|   | ||||
| @@ -5,6 +5,7 @@ export default { | ||||
| 	}, | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="loader" v-if="loading > 0"> | ||||
| 		<div class="d-flex justify-content-center align-items-center h-100"> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import moment from 'moment' | ||||
|  | ||||
| export default { | ||||
| @@ -54,6 +54,53 @@ export default { | ||||
| 		isSelected: function (id) { | ||||
| 			return mailbox.selected.indexOf(id) != -1 | ||||
| 		}, | ||||
|  | ||||
| 		toggleSelected: function (e, id) { | ||||
| 			e.preventDefault() | ||||
|  | ||||
| 			if (this.isSelected(id)) { | ||||
| 				mailbox.selected = mailbox.selected.filter(function (ele) { | ||||
| 					return ele != id | ||||
| 				}) | ||||
| 			} else { | ||||
| 				mailbox.selected.push(id) | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		selectRange: function (e, id) { | ||||
| 			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 | ||||
| 			} | ||||
|  | ||||
| 			if (lastSelected === false) { | ||||
| 				mailbox.selected.push(id) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			for (let d of mailbox.messages) { | ||||
| 				if (selecting) { | ||||
| 					if (!this.isSelected(d.ID)) { | ||||
| 						mailbox.selected.push(d.ID) | ||||
| 					} | ||||
| 					if (d.ID == lastSelected || d.ID == id) { | ||||
| 						// reached backwards select | ||||
| 						break | ||||
| 					} | ||||
| 				} else if (d.ID == id || d.ID == lastSelected) { | ||||
| 					if (!this.isSelected(d.ID)) { | ||||
| 						mailbox.selected.push(d.ID) | ||||
| 					} | ||||
| 					selecting = true | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
| @@ -61,13 +108,10 @@ export default { | ||||
| <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" :to="'/view/' + message.ID" :key="message.ID" :id="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' : ''"> | ||||
| 				<!-- <a v-for="message in messages" :href="'#' + message.ID" :key="message.ID" | ||||
| 				Av-on:click.ctrl="toggleSelected($event, message.ID)" Av-on:click.shift="selectRange($event, 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' : ''"> --> | ||||
| 				:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''" | ||||
| 				v-on:click.ctrl="toggleSelected($event, message.ID)" v-on: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> | ||||
| @@ -114,6 +158,9 @@ export default { | ||||
| 		</div> | ||||
| 	</template> | ||||
| 	<template v-else> | ||||
| 		<p class="text-center mt-5">There are no messages</p> | ||||
| 		<p class="text-center mt-5"> | ||||
| 			<template v-if="getSearch()">No results for <code>{{ getSearch() }}</code></template> | ||||
| 			<template v-else>No messages in your mailbox</template> | ||||
| 		</p> | ||||
| 	</template> | ||||
| </template> | ||||
|   | ||||
| @@ -1,112 +0,0 @@ | ||||
| <script> | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
| import { pagination } from '../stores/pagination.js' | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		reloadInbox: function () { | ||||
| 			pagination.start = 0 | ||||
| 			this.$emit('loadMessages') | ||||
| 		}, | ||||
|  | ||||
|  | ||||
| 		markAllRead: function () { | ||||
| 			let self = this | ||||
| 			let uri = self.$router.resolve(`/api/v1/messages`).href | ||||
| 			self.put(uri, { 'read': true }, function (response) { | ||||
| 				window.scrollInPlace = true | ||||
| 				self.$emit('loadMessages') | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		deleteAllMessages: function () { | ||||
| 			let self = this | ||||
| 			let uri = self.$router.resolve(`/api/v1/messages`).href | ||||
| 			self.delete(uri, false, function (response) { | ||||
| 				pagination.start = 0 | ||||
| 				self.$emit('loadMessages') | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="list-group my-2"> | ||||
| 		<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> | ||||
| 			<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"> | ||||
| 				{{ formatNumber(mailbox.unread) }} | ||||
| 			</span> | ||||
| 		</button> | ||||
|  | ||||
| 		<button class="list-group-item list-group-item-action" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal" | ||||
| 			:disabled="!mailbox.unread"> | ||||
| 			<i class="bi bi-eye-fill me-1"></i> | ||||
| 			Mark all read | ||||
| 		</button> | ||||
|  | ||||
| 		<button 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> | ||||
|  | ||||
| 	</div> | ||||
|  | ||||
| 	<!-- Modal --> | ||||
| 	<div class="modal fade" id="MarkAllReadModal" 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> | ||||
| 					<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. | ||||
| 				</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> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<!-- Modal --> | ||||
| 	<div class="modal fade" id="DeleteAllModal" 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> | ||||
| 					<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>. | ||||
| 				</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> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
							
								
								
									
										139
									
								
								server/ui-src/components/NavMailbox.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								server/ui-src/components/NavMailbox.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | ||||
| <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' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		NavSelected, | ||||
| 		AjaxLoader, | ||||
| 	}, | ||||
|  | ||||
| 	props: { | ||||
| 		modals: { | ||||
| 			type: Boolean, | ||||
| 			default: false, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		reloadInbox: function () { | ||||
| 			pagination.start = 0 | ||||
| 			this.loadMessages() | ||||
| 		}, | ||||
|  | ||||
|  | ||||
| 		loadMessages: function () { | ||||
| 			this.hideNav() // hide mobile menu | ||||
| 			this.$emit('loadMessages') | ||||
| 		}, | ||||
|  | ||||
| 		markAllRead: function () { | ||||
| 			let self = this | ||||
| 			self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) { | ||||
| 				window.scrollInPlace = true | ||||
| 				self.loadMessages() | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		deleteAllMessages: function () { | ||||
| 			let self = this | ||||
| 			self.delete(self.resolve(`/api/v1/messages`), false, function (response) { | ||||
| 				pagination.start = 0 | ||||
| 				self.loadMessages() | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<template v-if="!modals"> | ||||
| 		<div class="list-group my-2"> | ||||
| 			<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> | ||||
| 				<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"> | ||||
| 					{{ formatNumber(mailbox.unread) }} | ||||
| 				</span> | ||||
| 			</button> | ||||
|  | ||||
| 			<template v-if="!mailbox.selected.length"> | ||||
| 				<button class="list-group-item list-group-item-action" data-bs-toggle="modal" | ||||
| 					data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread"> | ||||
| 					<i class="bi bi-eye-fill me-1"></i> | ||||
| 					Mark all read | ||||
| 				</button> | ||||
|  | ||||
| 				<button 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> | ||||
|  | ||||
| 			<NavSelected @loadMessages="loadMessages" /> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template v-else> | ||||
| 		<!-- Modals --> | ||||
| 		<div class="modal fade" id="MarkAllReadModal" 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> | ||||
| 						<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. | ||||
| 					</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> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="modal fade" id="DeleteAllModal" 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> | ||||
| 						<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>. | ||||
| 					</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> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
							
								
								
									
										115
									
								
								server/ui-src/components/NavSearch.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								server/ui-src/components/NavSearch.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| <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' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		NavSelected, | ||||
| 		AjaxLoader, | ||||
| 	}, | ||||
|  | ||||
| 	props: { | ||||
| 		modals: { | ||||
| 			type: Boolean, | ||||
| 			default: false, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		loadMessages: function () { | ||||
| 			this.hideNav() // hide mobile menu | ||||
| 			this.$emit('loadMessages') | ||||
| 		}, | ||||
|  | ||||
| 		deleteAllMessages: function () { | ||||
| 			let s = this.getSearch() | ||||
| 			if (!s) { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			let self = this | ||||
|  | ||||
| 			let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s) | ||||
| 			this.delete(uri, false, function (response) { | ||||
| 				self.$router.push('/') | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<template v-if="!modals"> | ||||
| 		<div class="list-group my-2"> | ||||
| 			<RouterLink to="/" class="list-group-item list-group-item-action"> | ||||
| 				<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"> | ||||
| 					{{ formatNumber(mailbox.unread) }} | ||||
| 				</span> | ||||
| 			</RouterLink> | ||||
| 			<template v-if="!mailbox.selected.length"> | ||||
| 				<button 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> | ||||
|  | ||||
| 			<!-- <button class="list-group-item list-group-item-action" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal" | ||||
| 				:disabled="!mailbox.unread"> | ||||
| 				<i class="bi bi-eye-fill me-1"></i> | ||||
| 				Mark all read | ||||
| 			</button> | ||||
| 	 | ||||
| 			<button 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> --> | ||||
|  | ||||
| 			<NavSelected @loadMessages="loadMessages" /> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template v-else> | ||||
| 		<!-- Modals --> | ||||
| 		<div class="modal fade" id="DeleteAllModal" 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> | ||||
| 						<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 | ||||
| 						<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> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
							
								
								
									
										120
									
								
								server/ui-src/components/NavSelected.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								server/ui-src/components/NavSelected.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| <script> | ||||
| import AjaxLoader from './AjaxLoader.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
|  | ||||
| export default { | ||||
|     mixins: [CommonMixins], | ||||
|  | ||||
|     components: { | ||||
|         AjaxLoader, | ||||
|     }, | ||||
|  | ||||
|     emits: ['loadMessages'], | ||||
|  | ||||
|     data() { | ||||
|         return { | ||||
|             mailbox, | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     methods: { | ||||
|         loadMessages: function () { | ||||
|             this.$emit('loadMessages') | ||||
|         }, | ||||
|  | ||||
|         // mark selected messages as read | ||||
|         markSelectedRead: function () { | ||||
|             let self = this | ||||
|             if (!mailbox.selected.length) { | ||||
|                 return false | ||||
|             } | ||||
|             self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) { | ||||
|                 window.scrollInPlace = true | ||||
|                 self.loadMessages() | ||||
|             }) | ||||
|         }, | ||||
|  | ||||
|         isSelected: function (id) { | ||||
|             return mailbox.selected.indexOf(id) != -1 | ||||
|         }, | ||||
|  | ||||
|         // mark selected messages as unread | ||||
|         markSelectedUnread: function () { | ||||
|             let self = this | ||||
|             if (!mailbox.selected.length) { | ||||
|                 return false | ||||
|             } | ||||
|             self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) { | ||||
|                 window.scrollInPlace = true | ||||
|                 self.loadMessages() | ||||
|             }) | ||||
|         }, | ||||
|  | ||||
|         // universal handler to delete current or selected messages | ||||
|         deleteMessages: function () { | ||||
|             let ids = [] | ||||
|             let self = this | ||||
|             ids = JSON.parse(JSON.stringify(mailbox.selected)) | ||||
|             if (!ids.length) { | ||||
|                 return false | ||||
|             } | ||||
|             self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) { | ||||
|                 window.scrollInPlace = true | ||||
|                 self.loadMessages() | ||||
|             }) | ||||
|         }, | ||||
|  | ||||
|         // test if any selected emails are unread | ||||
|         selectedHasUnread: function () { | ||||
|             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: function () { | ||||
|             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 | ||||
|         }, | ||||
|     } | ||||
| } | ||||
| </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> | ||||
|  | ||||
|     <AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
| @@ -1,6 +1,6 @@ | ||||
| <script> | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| 
 | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| @@ -13,14 +13,13 @@ export default { | ||||
| 
 | ||||
| 	methods: { | ||||
| 		inSearch: function (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 | ||||
| 			} | ||||
| 
 | ||||
| 			let re = new RegExp(`\\btag:"?${tag}"?\\b`, 'i'); | ||||
| 
 | ||||
| 			let re = new RegExp(`\\btag:"?${tag}"?\\b`, 'i') | ||||
| 			return query.match(re) | ||||
| 		} | ||||
| 	} | ||||
| @@ -44,7 +43,7 @@ export default { | ||||
| 			</ul> | ||||
| 		</div> | ||||
| 		<div class="list-group mt-1 mb-5 pb-3"> | ||||
| 			<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" | ||||
| 			<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav" | ||||
| 				: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> | ||||
| @@ -1,19 +1,27 @@ | ||||
| <script> | ||||
| import { mailbox } from "../stores/mailbox.js" | ||||
| import { pagination } from "../stores/pagination.js" | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { Toast } from 'bootstrap' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			pagination, | ||||
| 			mailbox, | ||||
| 			toastMessage: false, //  | ||||
| 			toastMessage: false, | ||||
| 			reconnectRefresh: false, | ||||
| 			socketURI: false, | ||||
| 			pauseNotifications: false, // prevent spamming | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		let proto = location.protocol == 'https:' ? 'wss' : 'ws' | ||||
| 		this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`) | ||||
|  | ||||
| 		this.connect() | ||||
|  | ||||
| 		mailbox.notificationsSupported = window.isSecureContext | ||||
| @@ -24,10 +32,7 @@ export default { | ||||
| 	methods: { | ||||
| 		// websocket connect | ||||
| 		connect: function () { | ||||
| 			let proto = location.protocol == 'https:' ? 'wss' : 'ws' | ||||
| 			let ws = new WebSocket( | ||||
| 				proto + "://" + document.location.host + this.$router.resolve(`api/events`).href | ||||
| 			) | ||||
| 			let ws = new WebSocket(this.socketURI) | ||||
| 			let self = this | ||||
| 			ws.onmessage = function (e) { | ||||
| 				let response = JSON.parse(e.data) | ||||
| @@ -38,19 +43,17 @@ export default { | ||||
| 				if (response.Type == "new" && response.Data) { | ||||
| 					if (!mailbox.searching) { | ||||
| 						if (pagination.start < 1) { | ||||
| 							// first page | ||||
| 							// push results directly into first page | ||||
| 							mailbox.messages.unshift(response.Data) | ||||
| 							if (mailbox.messages.length > pagination.limit) { | ||||
| 								mailbox.messages.pop() | ||||
| 							} | ||||
| 						} else { | ||||
| 							// update pagination offset | ||||
| 							pagination.start++ | ||||
| 						} | ||||
| 					} | ||||
|  | ||||
| 					mailbox.total++ | ||||
| 					mailbox.unread++ | ||||
|  | ||||
| 					for (let i in response.Data.Tags) { | ||||
| 						if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) { | ||||
| 							mailbox.tags.push(response.Data.Tags[i]) | ||||
| @@ -59,14 +62,23 @@ export default { | ||||
| 					} | ||||
|  | ||||
| 					// send notifications | ||||
| 					let from = response.Data.From != null ? response.Data.From.Address : '[unknown]' | ||||
| 					self.browserNotify("New mail from: " + from, response.Data.Subject) | ||||
| 					self.setMessageToast(response.Data) | ||||
| 					if (!self.pauseNotifications) { | ||||
| 						self.pauseNotifications = true | ||||
| 						let from = response.Data.From != null ? response.Data.From.Address : '[unknown]' | ||||
| 						self.browserNotify("New mail from: " + from, response.Data.Subject) | ||||
| 						self.setMessageToast(response.Data) | ||||
| 						// delay notifications by 2s | ||||
| 						window.setTimeout(() => { self.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) | ||||
| 				} else if (response.Type == "stats" && response.Data) { | ||||
| 					// refresh mailbox stats | ||||
| 					mailbox.total = response.Data.Total | ||||
| 					mailbox.unread = response.Data.Unread | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| @@ -102,7 +114,7 @@ export default { | ||||
| 				let b = message.Subject | ||||
| 				let options = { | ||||
| 					body: message, | ||||
| 					icon: 'notification.png' | ||||
| 					icon: this.resolve('/notification.png') | ||||
| 				} | ||||
| 				new Notification(title, options) | ||||
| 			} | ||||
| @@ -160,4 +172,5 @@ export default { | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div></template> | ||||
| 	</div> | ||||
| </template> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <script> | ||||
| import CommonMixins from "../mixins/CommonMixins" | ||||
| import { pagination } from "../stores/pagination.js" | ||||
| import { mailbox } from "../stores/mailbox.js" | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { mailbox } from '../stores/mailbox' | ||||
| import { pagination } from '../stores/pagination' | ||||
|  | ||||
| export default { | ||||
|  | ||||
| @@ -13,7 +13,6 @@ export default { | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
|  | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			pagination, | ||||
| @@ -23,41 +22,41 @@ export default { | ||||
|  | ||||
| 	computed: { | ||||
| 		canPrev: function () { | ||||
| 			return this.pagination.start > 0 | ||||
| 			return pagination.start > 0 | ||||
| 		}, | ||||
|  | ||||
| 		canNext: function () { | ||||
| 			return this.total > (this.pagination.start + this.mailbox.messages.length) | ||||
| 			return this.total > (pagination.start + mailbox.messages.length) | ||||
| 		}, | ||||
|  | ||||
| 		// returns the number of next X messages | ||||
| 		nextMessages: function () { | ||||
| 			let t = pagination.start + parseInt(pagination.limit, 10); | ||||
| 			let t = pagination.start + parseInt(pagination.limit, 10) | ||||
| 			if (t > this.total) { | ||||
| 				t = this.total | ||||
| 			} | ||||
|  | ||||
| 			return t | ||||
| 		} | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		changeLimit: function () { | ||||
| 			this.pagination.start = 0 | ||||
| 			pagination.start = 0 | ||||
| 			this.$emit('loadMessages') | ||||
| 		}, | ||||
|  | ||||
| 		viewNext: function () { | ||||
| 			this.pagination.start = parseInt(this.pagination.start, 10) + parseInt(this.pagination.limit, 10) | ||||
| 			pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10) | ||||
| 			this.$emit('loadMessages') | ||||
| 		}, | ||||
|  | ||||
| 		viewPrev: function () { | ||||
| 			let s = this.pagination.start - this.pagination.limit | ||||
| 			let s = pagination.start - pagination.limit | ||||
| 			if (s < 0) { | ||||
| 				s = 0 | ||||
| 			} | ||||
| 			this.pagination.start = s | ||||
| 			pagination.start = s | ||||
| 			this.$emit('loadMessages') | ||||
| 		}, | ||||
| 	} | ||||
| @@ -65,8 +64,8 @@ export default { | ||||
|  | ||||
| </script> | ||||
| <template> | ||||
| 	<select v-model="pagination.limit" @change="changeLimit" | ||||
| 		class="form-select form-select-sm d-none d-md-inline w-auto me-2"> | ||||
| 	<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2" | ||||
| 		:disabled="total == 0"> | ||||
| 		<option value="25">25</option> | ||||
| 		<option value="50">50</option> | ||||
| 		<option value="100">100</option> | ||||
| @@ -74,9 +73,12 @@ export default { | ||||
| 	</select> | ||||
|  | ||||
| 	<small> | ||||
| 		{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }} | ||||
| 		<small>of</small> | ||||
| 		{{ formatNumber(total) }} | ||||
| 		<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" | ||||
|   | ||||
| @@ -1,47 +0,0 @@ | ||||
| <script> | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
| import { pagination } from '../stores/pagination.js' | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	emits: ['loadMessages'], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
|  | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="list-group my-2"> | ||||
| 		<RouterLink to="/" class="list-group-item list-group-item-action"> | ||||
| 			<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"> | ||||
| 				{{ formatNumber(mailbox.unread) }} | ||||
| 			</span> | ||||
| 		</RouterLink> | ||||
|  | ||||
| 		<!-- <button class="list-group-item list-group-item-action" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal" | ||||
| 			:disabled="!mailbox.unread"> | ||||
| 			<i class="bi bi-eye-fill me-1"></i> | ||||
| 			Mark all read | ||||
| 		</button> | ||||
|  | ||||
| 		<button 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> --> | ||||
|  | ||||
| 	</div> | ||||
| </template> | ||||
| @@ -1,12 +1,14 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
| import { pagination } from '../stores/pagination' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			search: '' | ||||
| 			search: '', | ||||
| 			pagination | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| @@ -22,12 +24,12 @@ export default { | ||||
|  | ||||
| 	methods: { | ||||
| 		searchFromURL: function () { | ||||
| 			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: function (e) { | ||||
| 			// let u = this.$router.resolve(`/search`).href; | ||||
| 			pagination.start = 0 | ||||
| 			if (this.search == '') { | ||||
| 				this.$router.push('/') | ||||
| 			} else { | ||||
| @@ -47,10 +49,7 @@ export default { | ||||
|  | ||||
| <template> | ||||
| 	<form v-on:submit="doSearch"> | ||||
| 		<div class="input-group"> | ||||
| 			<RouterLink to="/" class="navbar-brand d-md-none"> | ||||
| 				<img :src="baseURL + 'mailpit.svg'" alt="Mailpit"> | ||||
| 			</RouterLink> | ||||
| 		<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"> | ||||
|   | ||||
							
								
								
									
										41
									
								
								server/ui-src/components/message/Attachments.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								server/ui-src/components/message/Attachments.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
|  | ||||
| <script> | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
|  | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 		attachments: Object | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [commonMixins] | ||||
| } | ||||
| </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"> | ||||
| 			<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)"> | ||||
| 				<i class="bi" :class="attachmentIcon(part)"></i> | ||||
| 			</div> | ||||
| 			<div class="card-body border-0"> | ||||
| 				<p class="mb-1"> | ||||
| 					<i class="bi me-1" :class="attachmentIcon(part)"></i> | ||||
| 					<small>{{ getFileSize(part.Size) }}</small> | ||||
| 				</p> | ||||
| 				<p class="card-text mb-0 small"> | ||||
| 					{{ part.FileName != '' ? part.FileName : '[ unknown ]' }} | ||||
| 				</p> | ||||
| 			</div> | ||||
| 			<div class="card-footer small border-0 text-center text-truncate"> | ||||
| 				{{ part.FileName != '' ? part.FileName : '[ unknown ]' }} | ||||
| 			</div> | ||||
| 		</a> | ||||
| 	</div> | ||||
| </template> | ||||
							
								
								
									
										670
									
								
								server/ui-src/components/message/HTMLCheck.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										670
									
								
								server/ui-src/components/message/HTMLCheck.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,670 @@ | ||||
| <script> | ||||
| import Donut from 'vue-css-donut-chart/src/components/Donut.vue' | ||||
| import axios from 'axios' | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import { Tooltip } from 'bootstrap' | ||||
|  | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 	}, | ||||
|  | ||||
| 	components: { | ||||
| 		Donut, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["setHtmlScore", "setBadgeStyle"], | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			error: false, | ||||
| 			enabled: true, | ||||
| 			check: false, | ||||
| 			platforms: [], | ||||
| 			allPlatforms: { | ||||
| 				"windows": "Windows", | ||||
| 				"windows-mail": "Windows Mail", | ||||
| 				"outlook-com": "Outlook.com", | ||||
| 				"macos": "macOS", | ||||
| 				"ios": "iOS", | ||||
| 				"android": "Android", | ||||
| 				"desktop-webmail": "Desktop Webmail", | ||||
| 				"mobile-webmail": "Mobile Webmail", | ||||
| 			}, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.enabled = !localStorage.getItem('htmlCheckDisabled') | ||||
| 		this.loadConfig() | ||||
| 		this.doCheck() | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		summary: function () { | ||||
| 			let self = this | ||||
|  | ||||
| 			if (!this.enabled || !this.check) { | ||||
| 				return false | ||||
| 			} | ||||
|  | ||||
| 			let result = { | ||||
| 				Warnings: [], | ||||
| 				Total: { | ||||
| 					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])) | ||||
|  | ||||
| 				// for <script> test | ||||
| 				if (o.Results.length == 0) { | ||||
| 					result.Warnings.push(o) | ||||
| 					continue | ||||
| 				} | ||||
|  | ||||
| 				// filter by enabled platforms | ||||
| 				let results = o.Results.filter(function (w) { | ||||
| 					return self.platforms.indexOf(w.Platform) != -1 | ||||
| 				}) | ||||
|  | ||||
| 				if (results.length == 0) { | ||||
| 					continue | ||||
| 				} | ||||
|  | ||||
| 				// recalculate the percentages | ||||
| 				let y = 0, p = 0, n = 0 | ||||
|  | ||||
| 				results.forEach(function (r) { | ||||
| 					if (r.Support == "yes") { | ||||
| 						y++ | ||||
| 					} else if (r.Support == "partial") { | ||||
| 						p++ | ||||
| 					} else { | ||||
| 						n++ | ||||
| 					} | ||||
| 				}) | ||||
| 				let 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 | ||||
| 				} | ||||
|  | ||||
| 				result.Warnings.push(o) | ||||
| 			} | ||||
|  | ||||
| 			let maxPartial = 0, maxUnsupported = 0 | ||||
| 			result.Warnings.forEach(function (w) { | ||||
| 				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 | ||||
| 				} | ||||
|  | ||||
| 				// 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 (self.isPseudoClassOrAtRule(w.Title)) { | ||||
| 					scoreWeight = 0.05 | ||||
| 					w.PseudoClassOrAtRule = true | ||||
| 				} | ||||
|  | ||||
| 				let scorePartial = w.Score.Partial * scoreWeight | ||||
| 				let scoreUnsupported = w.Score.Unsupported * scoreWeight | ||||
| 				if (scorePartial > maxPartial) { | ||||
| 					maxPartial = scorePartial | ||||
| 				} | ||||
| 				if (scoreUnsupported > maxUnsupported) { | ||||
| 					maxUnsupported = scoreUnsupported | ||||
| 				} | ||||
| 			}) | ||||
|  | ||||
| 			// sort warnings by final score | ||||
| 			result.Warnings.sort(function (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 | ||||
|  | ||||
| 				if (self.isPseudoClassOrAtRule(a.Title)) { | ||||
| 					aWeight = 0.05 | ||||
| 				} | ||||
|  | ||||
| 				if (self.isPseudoClassOrAtRule(b.Title)) { | ||||
| 					bWeight = 0.05 | ||||
| 				} | ||||
|  | ||||
| 				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 | ||||
|  | ||||
| 			this.$emit('setHtmlScore', result.Total.Supported) | ||||
|  | ||||
| 			return result | ||||
| 		}, | ||||
|  | ||||
| 		graphSections: function () { | ||||
| 			let s = Math.round(this.summary.Total.Supported) | ||||
| 			let p = Math.round(this.summary.Total.Partial) | ||||
| 			let u = 100 - s - p | ||||
| 			return [ | ||||
| 				{ | ||||
| 					label: this.round2dm(this.summary.Total.Supported) + '% supported', | ||||
| 					value: s, | ||||
| 					color: '#198754' | ||||
| 				}, | ||||
| 				{ | ||||
| 					label: this.round2dm(this.summary.Total.Partial) + '% partially supported', | ||||
| 					value: p, | ||||
| 					color: '#ffc107' | ||||
| 				}, | ||||
| 				{ | ||||
| 					label: this.round2dm(this.summary.Total.Unsupported) + '% not supported', | ||||
| 					value: u, | ||||
| 					color: '#dc3545' | ||||
| 				} | ||||
| 			] | ||||
| 		}, | ||||
|  | ||||
| 		// colors depend on both varying unsupported & partially unsupported percentages | ||||
| 		scoreColor: function () { | ||||
| 			if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) { | ||||
| 				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-danger') | ||||
| 			return 'text-danger' | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		message: { | ||||
| 			handler() { | ||||
| 				this.$emit('setHtmlScore', false) | ||||
| 				this.doCheck() | ||||
| 			}, | ||||
| 			deep: true | ||||
| 		}, | ||||
| 		platforms(v) { | ||||
| 			localStorage.setItem('html-check-platforms', JSON.stringify(v)) | ||||
| 		}, | ||||
| 		enabled(v) { | ||||
| 			if (!v) { | ||||
| 				localStorage.setItem('htmlCheckDisabled', true) | ||||
| 				this.$emit('setHtmlScore', false) | ||||
| 			} else { | ||||
| 				localStorage.removeItem('htmlCheckDisabled') | ||||
| 				this.doCheck() | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		doCheck: function () { | ||||
| 			if (!this.enabled) { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			this.check = false | ||||
|  | ||||
| 			if (this.message.HTML == "") { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			let self = this | ||||
|  | ||||
| 			// ignore any error, do not show loader | ||||
| 			axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/html-check'), null) | ||||
| 				.then(function (result) { | ||||
| 					self.check = result.data | ||||
| 					self.error = false | ||||
|  | ||||
| 					// set tooltips | ||||
| 					window.setTimeout(function () { | ||||
| 						const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); | ||||
| 						[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) | ||||
| 					}, 500) | ||||
| 				}) | ||||
| 				.catch(function (error) { | ||||
| 					// handle error | ||||
| 					if (error.response && error.response.data) { | ||||
| 						// 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) { | ||||
| 							self.error = error.response.data.Error | ||||
| 						} else { | ||||
| 							self.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 | ||||
| 						self.error = 'Error sending data to the server. Please try again.' | ||||
| 					} else { | ||||
| 						// Something happened in setting up the request that triggered an Error | ||||
| 						self.error = error.message | ||||
| 					} | ||||
| 				}) | ||||
| 		}, | ||||
|  | ||||
| 		loadConfig: function () { | ||||
| 			let platforms = localStorage.getItem('html-check-platforms') | ||||
| 			if (platforms) { | ||||
| 				try { | ||||
| 					this.platforms = JSON.parse(platforms) | ||||
| 				} catch (e) { | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// set all options | ||||
| 			if (this.platforms.length == 0) { | ||||
| 				this.platforms = Object.keys(this.allPlatforms) | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		// return a platform's families (email clients) | ||||
| 		families: function (k) { | ||||
| 			if (this.check.Platforms[k]) { | ||||
| 				return this.check.Platforms[k] | ||||
| 			} | ||||
|  | ||||
| 			return [] | ||||
| 		}, | ||||
|  | ||||
| 		// return whether the test string is a pseudo class (:<test>) or at rule (@<test>) | ||||
| 		isPseudoClassOrAtRule: function (t) { | ||||
| 			return t.match(/^(:|@)/) | ||||
| 		}, | ||||
|  | ||||
| 		round: function (v) { | ||||
| 			return Math.round(v) | ||||
| 		}, | ||||
|  | ||||
| 		round2dm: function (v) { | ||||
| 			return Math.round(v * 100) / 100 | ||||
| 		}, | ||||
|  | ||||
| 		scrollToWarnings: function () { | ||||
| 			if (!this.$refs.warnings) { | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			this.$refs.warnings.scrollIntoView({ behavior: "smooth" }) | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<template v-if="error"> | ||||
| 		<p>HTML check failed to load:</p> | ||||
| 		<div class="alert alert-warning"> | ||||
| 			{{ error }} | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template v-if="!enabled"> | ||||
| 		<h2 class="h4 text-secondary">HTML check is currently disabled</h2> | ||||
| 		<p class="text-secondary"> | ||||
| 			This feature is currently in beta. Constructive feedback is welcome via | ||||
| 			<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>. | ||||
| 		</p> | ||||
| 		<div class="form-check form-switch"> | ||||
| 			<input class="form-check-input" type="checkbox" role="switch" v-model="enabled" id="inlineEnableHTMLCheck"> | ||||
| 			<label class="form-check-label" for="inlineEnableHTMLCheck"> | ||||
| 				Enable HTML check | ||||
| 			</label> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template v-if="summary"> | ||||
| 		<div class="mt-5 mb-3"> | ||||
| 			<div class="row w-100"> | ||||
| 				<div class="col-md-8"> | ||||
| 					<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> | ||||
| 						<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 class="text-nowrap"> | ||||
| 									<i class="bi bi-circle-fill text-warning"></i> | ||||
| 									{{ round2dm(summary.Total.Partial) }}% partially supported | ||||
| 								</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-secondary"> | ||||
| 								calculated from {{ formatNumber(check.Total.Tests) }} tests | ||||
| 							</p> | ||||
| 						</template> | ||||
| 					</Donut> | ||||
|  | ||||
| 					<div class="input-group justify-content-center mb-3"> | ||||
| 						<button class="btn btn-outline-secondary" data-bs-toggle="modal" | ||||
| 							data-bs-target="#AboutHTMLCheckResults"> | ||||
| 							<i class="bi bi-info-circle-fill"></i> | ||||
| 							Help | ||||
| 						</button> | ||||
| 						<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#HTMLCheckOptions"> | ||||
| 							<i class="bi bi-gear-fill"></i> | ||||
| 							Settings | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</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-secondary'" :title="families(k).join(', ')" | ||||
| 							data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')"> | ||||
| 							{{ p }} | ||||
| 						</label> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<template v-if="summary.Warnings.length"> | ||||
| 			<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"> | ||||
| 					<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"> | ||||
| 							<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"> | ||||
| 										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-bar bg-success"> | ||||
| 												{{ 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-bar progress-bar-striped bg-warning text-dark"> | ||||
| 												{{ 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-bar bg-danger"> | ||||
| 												{{ round(warning.Score.Unsupported) + '%' }} | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</button> | ||||
| 					</h2> | ||||
| 					<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings"> | ||||
| 						<div class="accordion-body"> | ||||
| 							<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. | ||||
| 								</span> | ||||
| 								<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span> | ||||
| 							</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 }} | ||||
| 										</span> | ||||
| 									</small> | ||||
| 								</p> | ||||
| 							</template> | ||||
|  | ||||
| 							<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 class="col-auto pe-0"> | ||||
| 										<span class="badge text-bg-secondary"> | ||||
| 											{{ i }} | ||||
| 										</span> | ||||
| 									</div> | ||||
| 									<div class="col" v-html="n"></div> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  | ||||
| 							<p class="small mt-3 mb-0" v-if="warning.URL"> | ||||
| 								<a :href="warning.URL" target="_blank">Online reference</a> | ||||
| 							</p> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			<p class="text-center text-secondary 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>. | ||||
| 			</p> | ||||
| 		</template> | ||||
|  | ||||
| 		<div class="modal fade" id="AboutHTMLCheckResults" 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> | ||||
| 						<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 					</div> | ||||
| 					<div class="modal-body"> | ||||
| 						<p> | ||||
| 							HTML check is currently in beta. Constructive feedback is welcome via | ||||
| 							<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>. | ||||
| 						</p> | ||||
| 						<div class="accordion" id="HTMLCheckAboutAccordion"> | ||||
| 							<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"> | ||||
| 										What is HTML check? | ||||
| 									</button> | ||||
| 								</h2> | ||||
| 								<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 platforms | ||||
| 										to give you some idea of the general compatibility of your HTML email. | ||||
| 									</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="#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 class="accordion-body"> | ||||
| 										<p> | ||||
| 											Internally the original HTML message is run against | ||||
| 											<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests | ||||
| 											(except for <code><script></code>) correspond to a test on | ||||
| 											<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and the | ||||
| 											final score is calculated using the available compatibility data. | ||||
| 										</p> | ||||
| 										<p> | ||||
| 											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> | ||||
| 											to matching HTML elements. This gives Mailpit fairly accurate results. | ||||
| 										</p> | ||||
| 										<p> | ||||
| 											CSS properties such as <code>@font-face</code>, <code>:visited</code>, | ||||
| 											<code>:hover</code> etc cannot be inlined however, so these are searched for | ||||
| 											within CSS blocks. This method is not accurate as Mailpit does not know how many | ||||
| 											nodes it actually applies to, if any, so they are weighted lightly (5%) as not | ||||
| 											to affect the score. An example of this would be any email linking to the full | ||||
| 											bootstrap CSS which contains dozens of unused attributes. | ||||
| 										</p> | ||||
| 										<p> | ||||
| 											All warnings are displayed with their respective support, including any specific | ||||
| 											notes, and it is up to you to decide what you do with that information and how | ||||
| 											badly it may impact your message. | ||||
| 										</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"> | ||||
| 										Is the final score accurate? | ||||
| 									</button> | ||||
| 								</h2> | ||||
| 								<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 | ||||
| 											compatibility score of an email. There is also no way to programmatically | ||||
| 											determine the relevance of a single test to the entire email. | ||||
| 										</p> | ||||
| 										<p> | ||||
| 											For each test, Mailpit calculates both the unsupported & partially-supported | ||||
| 											percentages in relation to the number of matches against the total number of | ||||
| 											nodes (elements) in the HTML. The maximum unsupported and partially-supported | ||||
| 											weighted scores are then used for the final score (ie: worst case scenario). | ||||
| 										</p> | ||||
| 										<p> | ||||
| 											To try explain this logic in very simple terms: Assuming a | ||||
| 											<code><script></code> node (element) has 100% failure (not supported in | ||||
| 											any email client), and a <code><p></code> node has 100% pass (supported). | ||||
| 										</p> | ||||
| 										<ul> | ||||
| 											<li> | ||||
| 												An email containing just a single <code><script></code>: the final | ||||
| 												score is 0% supported. | ||||
| 											</li> | ||||
| 											<li> | ||||
| 												An email containing just a <code><script></code> and a | ||||
| 												<code><p></code>: the final score is 50% supported. | ||||
| 											</li> | ||||
| 											<li> | ||||
| 												An email containing just a <code><script></code> and two | ||||
| 												<code><p></code>: the final score is 66.67% supported. | ||||
| 											</li> | ||||
| 										</ul> | ||||
| 										<p> | ||||
| 											Mailpit will sort the warnings according to their weighted unsupported scores. | ||||
| 										</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="#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 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 very good at | ||||
| 										turning invalid input into valid output. It is what it is... | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="modal-footer"> | ||||
| 						<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<div class="modal fade" id="HTMLCheckOptions" tabindex="-1" aria-labelledby="HTMLCheckOptionsLabel" 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="HTMLCheckOptionsLabel">HTML check options</h1> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<p> | ||||
| 						HTML check is currently in beta. Constructive feedback is welcome via | ||||
| 						<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>. | ||||
| 					</p> | ||||
| 					<div class="form-check form-switch mb-3"> | ||||
| 						<input class="form-check-input" type="checkbox" role="switch" v-model="enabled" | ||||
| 							id="HTMLCheckSwitch"> | ||||
| 						<label class="form-check-label" for="HTMLCheckSwitch"> | ||||
| 							<template v-if="enabled">HTML check is enabled in the web UI</template> | ||||
| 							<template v-else>HTML check is disabled in the web UI</template> | ||||
| 						</label> | ||||
| 					</div> | ||||
| 					<p class="mt-4 small text-center text-secondary"> | ||||
| 						HTML check can be globally disabled with <code>--disable-html-check</code><br> | ||||
| 						Remote CSS and font support can be globally blocked with <code>--block-remote-css-and-fonts</code> | ||||
| 					</p> | ||||
| 				</div> | ||||
| 				<div class="modal-footer"> | ||||
| 					<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
							
								
								
									
										38
									
								
								server/ui-src/components/message/Headers.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								server/ui-src/components/message/Headers.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
|  | ||||
| <script> | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         message: Object | ||||
|     }, | ||||
|  | ||||
|     mixins: [commonMixins], | ||||
|  | ||||
|     data() { | ||||
|         return { | ||||
|             headers: false | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     mounted() { | ||||
|         let self = this; | ||||
|         let uri = self.resolve('/api/v1/message/' + self.message.ID + '/headers') | ||||
|         self.get(uri, false, function (response) { | ||||
|             self.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> | ||||
							
								
								
									
										398
									
								
								server/ui-src/components/message/LinkCheck.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										398
									
								
								server/ui-src/components/message/LinkCheck.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,398 @@ | ||||
| <script> | ||||
| import axios from 'axios' | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
|  | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 	}, | ||||
|  | ||||
| 	emits: ["setLinkErrors"], | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			error: false, | ||||
| 			autoScan: false, | ||||
| 			followRedirects: false, | ||||
| 			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: function () { | ||||
| 			let results = {} | ||||
|  | ||||
| 			if (!this.check) { | ||||
| 				return results | ||||
| 			} | ||||
|  | ||||
| 			// group by status | ||||
| 			this.check.Links.forEach(function (r) { | ||||
| 				if (!results[r.StatusCode]) { | ||||
| 					let css = "" | ||||
| 					if (r.StatusCode >= 400 || r.StatusCode === 0) { | ||||
| 						css = "text-danger" | ||||
| 					} else if (r.StatusCode >= 300) { | ||||
| 						css = "text-info" | ||||
| 					} | ||||
|  | ||||
| 					if (r.StatusCode === 0) { | ||||
| 						r.Status = 'Cannot connect to server' | ||||
| 					} | ||||
| 					results[r.StatusCode] = { | ||||
| 						StatusCode: r.StatusCode, | ||||
| 						Status: r.Status, | ||||
| 						Class: css, | ||||
| 						URLS: [] | ||||
| 					} | ||||
| 				} | ||||
| 				results[r.StatusCode].URLS.push(r.URL) | ||||
| 			}) | ||||
|  | ||||
| 			let newArr = [] | ||||
|  | ||||
| 			for (const i in results) { | ||||
| 				newArr.push(results[i]) | ||||
| 			} | ||||
|  | ||||
| 			// sort statuses | ||||
| 			let sorted = newArr.sort((a, b) => { | ||||
| 				if (a.StatusCode === 0) { | ||||
| 					return false | ||||
| 				} | ||||
| 				return a.StatusCode < b.StatusCode | ||||
| 			}) | ||||
|  | ||||
|  | ||||
| 			return sorted | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		doCheck: function () { | ||||
| 			this.check = false | ||||
| 			this.loading = true | ||||
| 			let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check') | ||||
| 			if (this.followRedirects) { | ||||
| 				uri += '?follow=true' | ||||
| 			} | ||||
|  | ||||
| 			let self = this | ||||
| 			// ignore any error, do not show loader | ||||
| 			axios.get(uri, null) | ||||
| 				.then(function (result) { | ||||
| 					self.check = result.data | ||||
| 					self.error = false | ||||
|  | ||||
| 					self.$emit('setLinkErrors', result.data.Errors) | ||||
| 				}) | ||||
| 				.catch(function (error) { | ||||
| 					// handle error | ||||
| 					if (error.response && error.response.data) { | ||||
| 						// 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) { | ||||
| 							self.error = error.response.data.Error | ||||
| 						} else { | ||||
| 							self.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 | ||||
| 						self.error = 'Error sending data to the server. Please try again.' | ||||
| 					} else { | ||||
| 						// Something happened in setting up the request that triggered an Error | ||||
| 						self.error = error.message | ||||
| 					} | ||||
| 				}) | ||||
| 				.then(function (result) { | ||||
| 					// always run | ||||
| 					self.loading = false | ||||
| 				}) | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="pe-3"> | ||||
| 		<div class="row mb-3 align-items-center"> | ||||
| 			<div class="col"> | ||||
| 				<h4 class="mb-0"> | ||||
| 					<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 | ||||
| 						</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"> | ||||
| 						<i class="bi bi-info-circle-fill"></i> | ||||
| 						Help | ||||
| 					</button> | ||||
| 					<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#LinkCheckOptions"> | ||||
| 						<i class="bi bi-gear-fill"></i> | ||||
| 						Settings | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div v-if="!check"> | ||||
| 			<p class="text-secondary"> | ||||
| 				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"> | ||||
| 					<template v-if="loading"> | ||||
| 						Checking links | ||||
| 						<div class="ms-1 spinner-border spinner-border-sm text-light" role="status"> | ||||
| 							<span class="visually-hidden">Loading...</span> | ||||
| 						</div> | ||||
| 					</template> | ||||
| 					<template v-else> | ||||
| 						<i class="bi bi-check-square me-2"></i> | ||||
| 						Check message links | ||||
| 					</template> | ||||
| 				</button> | ||||
| 			</p> | ||||
| 		</div> | ||||
|  | ||||
| 		<div v-else v-for="s, k in groupedStatuses"> | ||||
| 			<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-secondary">({{ s.Status }})</small> | ||||
| 				</div> | ||||
| 				<ul class="list-group list-group-flush"> | ||||
| 					<li v-for="u in s.URLS" class="list-group-item"> | ||||
| 						<a :href="u" target="_blank" class="no-icon">{{ u }}</a> | ||||
| 					</li> | ||||
| 				</ul> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<template v-if="error"> | ||||
| 			<p>Link check failed to load:</p> | ||||
| 			<div class="alert alert-warning"> | ||||
| 				{{ error }} | ||||
| 			</div> | ||||
| 		</template> | ||||
|  | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="modal fade" id="LinkCheckOptions" 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> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<p> | ||||
| 						Link check is currently in beta. Constructive feedback is welcome via | ||||
| 						<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>. | ||||
| 					</p> | ||||
|  | ||||
| 					<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"> | ||||
| 						<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch"> | ||||
| 							<template v-if="followRedirects">Following HTTP redirects</template> | ||||
| 							<template v-else>Not following HTTP redirects</template> | ||||
| 						</label> | ||||
| 					</div> | ||||
|  | ||||
| 					<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"> | ||||
| 						<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> | ||||
| 						</label> | ||||
| 						<div class="form-text"> | ||||
| 							Note: Enabling auto checking will scan every link & image every time a message is opened. | ||||
| 							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> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="modal fade" id="AboutLinkCheckResults" 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> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<p> | ||||
| 						Link check is currently in beta. Constructive feedback is welcome via | ||||
| 						<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>. | ||||
| 					</p> | ||||
| 					<div class="accordion" id="LinkCheckAboutAccordion"> | ||||
| 						<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"> | ||||
| 									What is Link check? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<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 time, to | ||||
| 									test whether the link/image/stylesheet exists. | ||||
| 								</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="#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 class="accordion-body"> | ||||
| 									<p> | ||||
| 										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. | ||||
| 									</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"> | ||||
| 									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 class="accordion-body"> | ||||
| 									<p>This may be due to various reasons, for instance:</p> | ||||
| 									<ul> | ||||
| 										<li>The Mailpit server cannot resolve (DNS) the hostname of the URL.</li> | ||||
| 										<li>Mailpit is not allowed to access the URL.</li> | ||||
| 										<li> | ||||
| 											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> | ||||
| 									</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"> | ||||
| 									What are the risks of running Link check automatically? | ||||
| 								</button> | ||||
| 							</h2> | ||||
| 							<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 messages | ||||
| 										may have undesired consequences: | ||||
| 									</p> | ||||
| 									<ul> | ||||
| 										<li>If the message contains tracking links this may reveal your identity.</li> | ||||
| 										<li> | ||||
| 											If the message contains unsubscribe links, Link check could unintentionally | ||||
| 											unsubscribe you. | ||||
| 										</li> | ||||
| 										<li> | ||||
| 											To speed up the checking process, Link check will attempt 5 URLs at a time. This | ||||
| 											could lead to temporary heady load on the remote server. | ||||
| 										</li> | ||||
| 									</ul> | ||||
| 									<p> | ||||
| 										Unless you know what messages you receive, it is advised to only run the Link check | ||||
| 										manually. | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
|  | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="modal-footer"> | ||||
| 					<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
							
								
								
									
										458
									
								
								server/ui-src/components/message/Message.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										458
									
								
								server/ui-src/components/message/Message.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,458 @@ | ||||
|  | ||||
| <script> | ||||
| import Attachments from './Attachments.vue' | ||||
| import HTMLCheck from './HTMLCheck.vue' | ||||
| import Headers from './Headers.vue' | ||||
| import LinkCheck from './LinkCheck.vue' | ||||
| import Prism from 'prismjs' | ||||
| import Tags from 'bootstrap5-tags' | ||||
| import commonMixins from '../../mixins/CommonMixins' | ||||
| import { mailbox } from '../../stores/mailbox' | ||||
|  | ||||
| export default { | ||||
| 	props: { | ||||
| 		message: Object, | ||||
| 	}, | ||||
|  | ||||
| 	components: { | ||||
| 		Attachments, | ||||
| 		Headers, | ||||
| 		HTMLCheck, | ||||
| 		LinkCheck, | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			srcURI: false, | ||||
| 			iframes: [], // for resizing | ||||
| 			canSaveTags: false, // prevent auto-saving tags on render | ||||
| 			messageTags: [], | ||||
| 			loadHeaders: false, | ||||
| 			htmlScore: false, | ||||
| 			htmlScoreColor: false, | ||||
| 			linkCheckErrors: false, | ||||
| 			showMobileButtons: 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') { | ||||
| 				let self = this | ||||
| 				window.setTimeout(function () { | ||||
| 					self.resizeIFrames() | ||||
| 				}, 500) | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		let self = this | ||||
| 		self.canSaveTags = false | ||||
| 		self.messageTags = self.message.Tags | ||||
| 		self.renderUI() | ||||
|  | ||||
| 		window.addEventListener("resize", self.resizeIFrames) | ||||
|  | ||||
| 		let headersTab = document.getElementById('nav-headers-tab') | ||||
| 		headersTab.addEventListener('shown.bs.tab', function (event) { | ||||
| 			self.loadHeaders = true | ||||
| 		}) | ||||
|  | ||||
| 		let rawTab = document.getElementById('nav-raw-tab') | ||||
| 		rawTab.addEventListener('shown.bs.tab', function (event) { | ||||
| 			self.srcURI = self.resolve('/api/v1/message/' + self.message.ID + '/raw') | ||||
| 			self.resizeIFrames() | ||||
| 		}) | ||||
|  | ||||
| 		// manually refresh tags | ||||
| 		self.get(self.resolve(`/api/v1/tags`), false, function (response) { | ||||
| 			mailbox.tags = response.data | ||||
| 			self.$nextTick(function () { | ||||
| 				Tags.init('select[multiple]') | ||||
| 				// delay tag change detection to allow Tags to load | ||||
| 				window.setTimeout(function () { | ||||
| 					self.canSaveTags = true | ||||
| 				}, 200) | ||||
| 			}) | ||||
| 		}) | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		isHTMLTabSelected: function () { | ||||
| 			this.showMobileButtons = this.$refs.navhtml | ||||
| 				&& this.$refs.navhtml.classList.contains('active') | ||||
| 		}, | ||||
|  | ||||
| 		renderUI: function () { | ||||
| 			let self = this | ||||
|  | ||||
| 			// 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 | ||||
|  | ||||
| 			self.isHTMLTabSelected() | ||||
|  | ||||
| 			document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) { | ||||
| 				listObj.addEventListener('shown.bs.tab', function (event) { | ||||
| 					self.isHTMLTabSelected() | ||||
| 				}) | ||||
| 			}) | ||||
|  | ||||
| 			// delay 0.2s until vue has rendered the iframe content | ||||
| 			window.setTimeout(function () { | ||||
| 				let p = document.getElementById('preview-html') | ||||
| 				if (p) { | ||||
| 					// 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(/^http/)) { | ||||
| 							anchorEl.setAttribute('target', '_blank') | ||||
| 						} | ||||
| 					} | ||||
| 					self.resizeIFrames() | ||||
| 				} | ||||
| 			}, 200) | ||||
|  | ||||
| 			// html highlighting | ||||
| 			window.Prism = window.Prism || {} | ||||
| 			window.Prism.manual = true | ||||
| 			Prism.highlightAll() | ||||
| 		}, | ||||
|  | ||||
| 		resizeIframe: function (el) { | ||||
| 			let i = el.target | ||||
| 			i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px' | ||||
| 		}, | ||||
|  | ||||
| 		resizeIFrames: function () { | ||||
| 			if (this.scaleHTMLPreview != 'display') { | ||||
| 				return | ||||
| 			} | ||||
| 			let h = document.getElementById('preview-html') | ||||
| 			if (h) { | ||||
| 				h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px' | ||||
| 			} | ||||
|  | ||||
| 		}, | ||||
|  | ||||
| 		// set the iframe body & text colors based on current theme | ||||
| 		initRawIframe: function (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) | ||||
| 		}, | ||||
|  | ||||
| 		sanitizeHTML: function (h) { | ||||
| 			// remove <base/> tag if set | ||||
| 			return h.replace(/<base .*>/mi, '') | ||||
| 		}, | ||||
|  | ||||
| 		saveTags: function () { | ||||
| 			let self = this | ||||
|  | ||||
| 			var data = { | ||||
| 				ids: [this.message.ID], | ||||
| 				tags: this.messageTags | ||||
| 			} | ||||
|  | ||||
| 			self.put(self.resolve('/api/v1/tags'), data, function (response) { | ||||
| 				window.scrollInPlace = true | ||||
| 				self.$emit('loadMessages') | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		// Convert plain text to HTML including anchor links | ||||
| 		textToHTML: function (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" style="overflow-y: scroll;"> | ||||
| 		<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">{{ message.From.Name + " " }}</span> | ||||
| 									<span v-if="message.From.Address" class="small"> | ||||
| 										<{{ message.From.Address }}> | ||||
| 									</span> | ||||
| 								</span> | ||||
| 								<span v-else> | ||||
| 									[ Unknown ] | ||||
| 								</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 class="text-nowrap">{{ t.Name + " <" + t.Address + ">" }}</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> | ||||
| 									{{ t.Name + " <" + t.Address + ">" }} </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> | ||||
| 									{{ t.Name + " <" + t.Address + ">" }} | ||||
| 								</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"> | ||||
| 								<span v-for="(t, i) in message.ReplyTo"> | ||||
| 									<template v-if="i > 0">,</template> | ||||
| 									{{ t.Name + " <" + t.Address + ">" }} </span> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr v-if="message.ReturnPath && message.ReturnPath != message.From.Address" class="small"> | ||||
| 							<th class="text-nowrap">Return-Path</th> | ||||
| 							<td class="privacy text-body-secondary"><{{ message.ReturnPath }}></td> | ||||
| 						</tr> | ||||
| 						<tr> | ||||
| 							<th class="small">Subject</th> | ||||
| 							<td> | ||||
| 								<strong v-if="message.Subject != ''">{{ message.Subject }}</strong> | ||||
| 								<small class="text-body-secondary" v-else>[ no subject ]</small> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 						<tr class="d-md-none small"> | ||||
| 							<th class="small">Date</th> | ||||
| 							<td>{{ messageDate(message.Date) }}</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\-\ \_]){3,}$" | ||||
| 									data-separator="|,|"> | ||||
| 									<option value="">Type a tag...</option> | ||||
| 									<!-- you need at least one option with the placeholder --> | ||||
| 									<option v-for="t in mailbox.tags" :value="t">{{ t }}</option> | ||||
| 								</select> | ||||
| 								<div class="invalid-feedback">Invalid tag name</div> | ||||
| 							</td> | ||||
| 						</tr> | ||||
| 					</tbody> | ||||
| 				</table> | ||||
| 			</div> | ||||
| 			<div class="col-md-auto d-none d-md-block text-end mt-md-3"> | ||||
| 				<div class="mt-2 mt-md-0" v-if="allAttachments(message)"> | ||||
| 					<span class="badge rounded-pill text-bg-secondary p-2"> | ||||
| 						Attachment<span v-if="allAttachments(message).length > 1">s</span> | ||||
| 						({{ allAttachments(message).length }}) | ||||
| 					</span> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<nav> | ||||
| 			<div class="nav nav-tabs my-3" 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"> | ||||
| 					<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
| 						Checks | ||||
| 					</button> | ||||
| 					<ul class="dropdown-menu"> | ||||
| 						<li> | ||||
| 							<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" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''"> | ||||
| 								HTML Check | ||||
| 								<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false"> | ||||
| 									<small>{{ Math.floor(htmlScore) }}%</small> | ||||
| 								</span> | ||||
| 							</button> | ||||
| 						</li> | ||||
| 						<li> | ||||
| 							<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 | ||||
| 								<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> | ||||
| 						</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.uiConfig.DisableHTMLCheck && 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"> | ||||
| 					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> | ||||
|  | ||||
| 				<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons"> | ||||
| 					<template v-for="vals, 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> | ||||
| 			</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="sanitizeHTML(message.HTML)" | ||||
| 						v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%;"> | ||||
| 					</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><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.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message" | ||||
| 					@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" /> | ||||
| 			</div> | ||||
| 			<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab" | ||||
| 				tabindex="0"> | ||||
| 				<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
							
								
								
									
										135
									
								
								server/ui-src/components/message/Release.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								server/ui-src/components/message/Release.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | ||||
|  | ||||
| <script> | ||||
| 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, | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			addresses: [], | ||||
| 			mailbox, | ||||
| 			allAddresses: [], | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mixins: [commonMixins], | ||||
|  | ||||
| 	mounted() { | ||||
| 		let a = [] | ||||
| 		for (let 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 (let 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.addresses = this.allAddresses | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		// triggered manually after modal is shown | ||||
| 		initTags: function () { | ||||
| 			Tags.init("select[multiple]") | ||||
| 		}, | ||||
|  | ||||
| 		releaseMessage: function () { | ||||
| 			let self = this | ||||
| 			// set timeout to allow for user clicking send before the tag filter has applied the tag | ||||
| 			window.setTimeout(function () { | ||||
| 				if (!self.addresses.length) { | ||||
| 					return false | ||||
| 				} | ||||
|  | ||||
| 				let data = { | ||||
| 					to: self.addresses | ||||
| 				} | ||||
|  | ||||
| 				self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) { | ||||
| 					self.modal("ReleaseModal").hide() | ||||
| 				}) | ||||
| 			}, 100) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true"> | ||||
| 		<div class="modal-dialog modal-lg" v-if="message"> | ||||
| 			<div class="modal-content"> | ||||
| 				<div class="modal-header"> | ||||
| 					<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1> | ||||
| 					<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
| 				</div> | ||||
| 				<div class="modal-body"> | ||||
| 					<h6>Send this message to one or more addresses specified below.</h6> | ||||
| 					<div class="row"> | ||||
| 						<label class="col-sm-2 col-form-label text-body-secondary">From</label> | ||||
| 						<div class="col-sm-10"> | ||||
| 							<input type="text" aria-label="From address" readonly class="form-control-plaintext" | ||||
| 								:value="message.From.Address"> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="row"> | ||||
| 						<label class=" col-sm-2 col-form-label text-body-secondary">Subject</label> | ||||
| 						<div class="col-sm-10"> | ||||
| 							<input type="text" aria-label="Subject" readonly class="form-control-plaintext" | ||||
| 								:value="message.Subject"> | ||||
| 						</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" 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="|,|"> | ||||
| 								<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> | ||||
| 							</select> | ||||
| 							<div class="invalid-feedback">Invalid email address</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.RecipientAllowlist != ''"> | ||||
| 						Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected. | ||||
| 						<br class="d-none d-md-inline"> | ||||
| 						Configured allowlist: <b>{{ mailbox.uiConfig.MessageRelay.RecipientAllowlist }}</b> | ||||
| 					</div> | ||||
| 					<div class="form-text text-center"> | ||||
| 						Note: For testing purposes, a unique Message-Id will be generated on send. | ||||
| 						<br class="d-none d-md-inline"> | ||||
| 						SMTP delivery failures will bounce back to | ||||
| 						<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">{{ mailbox.uiConfig.MessageRelay.ReturnPath | ||||
| 						}}</b> | ||||
| 						<b v-else>{{ message.ReturnPath }}</b>. | ||||
| 					</div> | ||||
| 				</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> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
							
								
								
									
										147
									
								
								server/ui-src/components/message/Screenshot.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								server/ui-src/components/message/Screenshot.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
|  | ||||
| <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: function () { | ||||
|             this.loading = 1 | ||||
|             let self = this | ||||
|             // 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, function (match, p1, p2, p3) { | ||||
|                 if (typeof p2 === 'string') { | ||||
|                     return `url(${p2}${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `${p2})` | ||||
|                 } | ||||
|                 return `url(${proxy}?url=` + encodeURIComponent(self.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(self.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(self.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(self.decodeEntities(src))) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // set html with manipulated document content | ||||
|             this.html = new XMLSerializer().serializeToString(doc) | ||||
|         }, | ||||
|  | ||||
|         // HTML decode function | ||||
|         decodeEntities: function (s) { | ||||
|             let e = document.createElement('div') | ||||
|             e.innerHTML = s | ||||
|             let str = e.textContent | ||||
|             e.textContent = '' | ||||
|             return str | ||||
|         }, | ||||
|  | ||||
|         doScreenshot: function () { | ||||
|             let self = this | ||||
|             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 | ||||
|             } | ||||
|  | ||||
|             let 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 = self.message.ID + '.png' | ||||
|                 link.href = dataUrl | ||||
|                 link.click() | ||||
|                 self.loading = 0 | ||||
|                 self.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,17 +1,18 @@ | ||||
| import axios from 'axios' | ||||
| import { Modal } from 'bootstrap' | ||||
| import moment from 'moment' | ||||
| import ColorHash from 'color-hash' | ||||
| import { Modal, Offcanvas } from 'bootstrap' | ||||
|  | ||||
|  | ||||
| // FakeModal is used to return a fake Bootstrap modal | ||||
| // BootstrapElement is used to return a fake Bootstrap element | ||||
| // if the ID returns nothing to prevent errors. | ||||
| function FakeModal() { } | ||||
| FakeModal.prototype.hide = function () { } | ||||
| FakeModal.prototype.show = function () { } | ||||
| class BootstrapElement { | ||||
| 	constructor() { } | ||||
| 	hide() { } | ||||
| 	show() { } | ||||
| } | ||||
|  | ||||
| // Set up the color hash generator lightness and hue to ensure darker colors | ||||
| const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] }); | ||||
| const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] }) | ||||
|  | ||||
| /* Common mixin functions used in apps */ | ||||
| export default { | ||||
| @@ -19,36 +20,25 @@ export default { | ||||
| 		return { | ||||
| 			loading: 0, | ||||
| 			tagColorCache: {}, | ||||
| 			// showTagColors: true, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	beforeMount() { | ||||
| 		// this.baseURL = this.$router.resolve(`/`).href | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		// this.showTagColors = localStorage.getItem('showTagsColors') | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		baseURL() { | ||||
| 			return window.baseURL | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		resolve: function (u) { | ||||
| 			return this.$router.resolve(u).href | ||||
| 		}, | ||||
|  | ||||
| 		getFileSize: function (bytes) { | ||||
| 			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]; | ||||
| 			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] | ||||
| 		}, | ||||
|  | ||||
| 		formatNumber: function (nr) { | ||||
| 			return new Intl.NumberFormat().format(nr); | ||||
| 			return new Intl.NumberFormat().format(nr) | ||||
| 		}, | ||||
|  | ||||
| 		messageDate: function (d) { | ||||
| 			return moment(d).format('ddd, D MMM YYYY, h:mm a'); | ||||
| 			return moment(d).format('ddd, D MMM YYYY, h:mm a') | ||||
| 		}, | ||||
|  | ||||
| 		tagEncodeURI: function (tag) { | ||||
| @@ -56,7 +46,21 @@ export default { | ||||
| 				tag = `"${tag}"` | ||||
| 			} | ||||
|  | ||||
| 			return 'tag:' + encodeURIComponent(`${tag}`) | ||||
| 			return encodeURIComponent(`tag:${tag}`) | ||||
| 		}, | ||||
|  | ||||
| 		getSearch: function () { | ||||
| 			if (!window.location.search) { | ||||
| 				return false | ||||
| 			} | ||||
|  | ||||
| 			const urlParams = new URLSearchParams(window.location.search) | ||||
| 			const q = urlParams.get('q').trim() | ||||
| 			if (q == '') { | ||||
| 				return false | ||||
| 			} | ||||
|  | ||||
| 			return q | ||||
| 		}, | ||||
|  | ||||
| 		// Ajax error message | ||||
| @@ -66,29 +70,37 @@ 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 | ||||
| 				// `error.request` is an instance of XMLHttpRequest in the browser and an instance of | ||||
| 				// http.ClientRequest in node.js | ||||
| 				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) | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		// generic modal get/set function | ||||
| 		modal: function (id) { | ||||
| 			let e = document.getElementById(id); | ||||
| 			let e = document.getElementById(id) | ||||
| 			if (e) { | ||||
| 				return Modal.getOrCreateInstance(e); | ||||
| 				return Modal.getOrCreateInstance(e) | ||||
| 			} | ||||
| 			// in case there are open/close actions | ||||
| 			return new FakeModal(); | ||||
| 			return new BootstrapElement() | ||||
| 		}, | ||||
|  | ||||
| 		// close mobile navigation | ||||
| 		hideNav: function () { | ||||
| 			let e = document.getElementById('offcanvas') | ||||
| 			if (e) { | ||||
| 				Offcanvas.getOrCreateInstance(e).hide() | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		/** | ||||
| @@ -99,17 +111,17 @@ export default { | ||||
| 		 * @params function callback function | ||||
| 		 */ | ||||
| 		get: function (url, values, callback) { | ||||
| 			let self = this; | ||||
| 			self.loading++; | ||||
| 			let self = this | ||||
| 			self.loading++ | ||||
| 			axios.get(url, { params: values }) | ||||
| 				.then(callback) | ||||
| 				.catch(self.handleError) | ||||
| 				.then(function () { | ||||
| 					// always executed | ||||
| 					if (self.loading > 0) { | ||||
| 						self.loading--; | ||||
| 						self.loading-- | ||||
| 					} | ||||
| 				}); | ||||
| 				}) | ||||
| 		}, | ||||
|  | ||||
| 		/** | ||||
| @@ -120,17 +132,17 @@ export default { | ||||
| 		 * @params function callback function | ||||
| 		 */ | ||||
| 		post: function (url, data, callback) { | ||||
| 			let self = this; | ||||
| 			self.loading++; | ||||
| 			let self = this | ||||
| 			self.loading++ | ||||
| 			axios.post(url, data) | ||||
| 				.then(callback) | ||||
| 				.catch(self.handleError) | ||||
| 				.then(function () { | ||||
| 					// always executed | ||||
| 					if (self.loading > 0) { | ||||
| 						self.loading--; | ||||
| 						self.loading-- | ||||
| 					} | ||||
| 				}); | ||||
| 				}) | ||||
| 		}, | ||||
|  | ||||
| 		/** | ||||
| @@ -141,17 +153,17 @@ export default { | ||||
| 		 * @params function callback function | ||||
| 		 */ | ||||
| 		delete: function (url, data, callback) { | ||||
| 			let self = this; | ||||
| 			self.loading++; | ||||
| 			let self = this | ||||
| 			self.loading++ | ||||
| 			axios.delete(url, { data: data }) | ||||
| 				.then(callback) | ||||
| 				.catch(self.handleError) | ||||
| 				.then(function () { | ||||
| 					// always executed | ||||
| 					if (self.loading > 0) { | ||||
| 						self.loading--; | ||||
| 						self.loading-- | ||||
| 					} | ||||
| 				}); | ||||
| 				}) | ||||
| 		}, | ||||
|  | ||||
| 		/** | ||||
| @@ -162,73 +174,73 @@ export default { | ||||
| 		 * @params function callback function | ||||
| 		 */ | ||||
| 		put: function (url, data, callback) { | ||||
| 			let self = this; | ||||
| 			self.loading++; | ||||
| 			let self = this | ||||
| 			self.loading++ | ||||
| 			axios.put(url, data) | ||||
| 				.then(callback) | ||||
| 				.catch(self.handleError) | ||||
| 				.then(function () { | ||||
| 					// always executed | ||||
| 					if (self.loading > 0) { | ||||
| 						self.loading--; | ||||
| 						self.loading-- | ||||
| 					} | ||||
| 				}); | ||||
| 				}) | ||||
| 		}, | ||||
|  | ||||
| 		allAttachments: function (message) { | ||||
| 			let a = []; | ||||
| 			let a = [] | ||||
| 			for (let i in message.Attachments) { | ||||
| 				a.push(message.Attachments[i]); | ||||
| 				a.push(message.Attachments[i]) | ||||
| 			} | ||||
| 			for (let i in message.OtherParts) { | ||||
| 				a.push(message.OtherParts[i]); | ||||
| 				a.push(message.OtherParts[i]) | ||||
| 			} | ||||
| 			for (let i in message.Inline) { | ||||
| 				a.push(message.Inline[i]); | ||||
| 				a.push(message.Inline[i]) | ||||
| 			} | ||||
|  | ||||
| 			return a.length ? a : false; | ||||
| 			return a.length ? a : false | ||||
| 		}, | ||||
|  | ||||
| 		isImage(a) { | ||||
| 			return a.ContentType.match(/^image\//); | ||||
| 			return a.ContentType.match(/^image\//) | ||||
| 		}, | ||||
|  | ||||
| 		attachmentIcon: function (a) { | ||||
| 			let ext = a.FileName.split('.').pop().toLowerCase(); | ||||
| 			let 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'; | ||||
| 				return 'bi-file-pdf-fill' | ||||
| 			} | ||||
| 			if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) { | ||||
| 				return 'bi-file-word-fill'; | ||||
| 				return 'bi-file-word-fill' | ||||
| 			} | ||||
| 			if (['xls', 'xlsx', 'ods'].includes(ext)) { | ||||
| 				return 'bi-file-spreadsheet-fill'; | ||||
| 				return 'bi-file-spreadsheet-fill' | ||||
| 			} | ||||
| 			if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) { | ||||
| 				return 'bi-file-slides-fill'; | ||||
| 				return 'bi-file-slides-fill' | ||||
| 			} | ||||
| 			if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) { | ||||
| 				return 'bi-file-zip-fill'; | ||||
| 				return 'bi-file-zip-fill' | ||||
| 			} | ||||
| 			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'; | ||||
| 				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. | ||||
| @@ -241,18 +253,5 @@ export default { | ||||
|  | ||||
| 			return this.tagColorCache[s] | ||||
| 		}, | ||||
|  | ||||
| 		// toggleTagColors: function () { | ||||
| 		// 	if (this.showTagColors) { | ||||
| 		// 		localStorage.removeItem('showTagsColors') | ||||
| 		// 		this.showTagColors = false | ||||
| 		// 	} else { | ||||
| 		// 		localStorage.setItem('showTagsColors', '1') | ||||
| 		// 		this.showTagColors = true | ||||
| 		// 	} | ||||
| 		// } | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
| // export default commonMixins; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import CommonMixins from './CommonMixins.js' | ||||
| import { mailbox } from "../stores/mailbox.js" | ||||
| import { pagination } from "../stores/pagination.js" | ||||
| import { mailbox } from '../stores/mailbox.js' | ||||
| import { pagination } from '../stores/pagination.js' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
| @@ -26,7 +26,7 @@ export default { | ||||
|  | ||||
| 	methods: { | ||||
| 		reloadMailbox: function () { | ||||
| 			pagination.start = 0; | ||||
| 			pagination.start = 0 | ||||
| 			this.loadMessages() | ||||
| 		}, | ||||
|  | ||||
| @@ -54,16 +54,29 @@ export default { | ||||
| 				// ensure the pagination remains consistent | ||||
| 				pagination.start = response.data.start | ||||
|  | ||||
| 				// pagination.total = response.data.messages_count | ||||
| 				// self.existingTags = JSON.parse(JSON.stringify(self.tags)) | ||||
|  | ||||
| 				// if pagination > 0 && results == 0 reload first page (prune) | ||||
| 				if (response.data.count == 0 && response.data.start > 0) { | ||||
| 					pagination.start = 0 | ||||
| 					return self.loadMessages() | ||||
| 				} | ||||
|  | ||||
| 				if (!window.scrollInPlace) { | ||||
| 				if (mailbox.lastMessage) { | ||||
| 					window.setTimeout(() => { | ||||
| 						let m = document.getElementById(mailbox.lastMessage) | ||||
| 						if (m) { | ||||
| 							m.focus() | ||||
| 							// m.scrollIntoView({ behavior: 'smooth', block: 'center' }) | ||||
| 							m.scrollIntoView({ block: 'center' }) | ||||
| 						} else { | ||||
| 							let mp = document.getElementById('message-page') | ||||
| 							if (mp) { | ||||
| 								mp.scrollTop = 0 | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 						mailbox.lastMessage = false | ||||
| 					}, 50) | ||||
|  | ||||
| 				} else if (!window.scrollInPlace) { | ||||
| 					let mp = document.getElementById('message-page') | ||||
| 					if (mp) { | ||||
| 						mp.scrollTop = 0 | ||||
|   | ||||
| @@ -1,10 +1,8 @@ | ||||
| import { createRouter, createWebHistory } from 'vue-router' | ||||
| import MailboxView from '../views/MailboxView.vue' | ||||
| import SearchView from '../views/SearchView.vue' | ||||
| import MessageView from '../views/MessageView.vue' | ||||
| import NotFoundView from '../views/NotFoundView.vue' | ||||
| // import EditView from '../views/EditView.vue' | ||||
| // import StatsView from '../views/StatsView.vue' | ||||
| // import NotFound from '../views/NotFound.vue' | ||||
| import SearchView from '../views/SearchView.vue' | ||||
|  | ||||
| let d = document.getElementById('app') | ||||
| let webroot = '/' | ||||
| @@ -18,19 +16,16 @@ const router = createRouter({ | ||||
| 	routes: [ | ||||
| 		{ | ||||
| 			path: '/', | ||||
| 			// name: 'home', | ||||
| 			component: MailboxView | ||||
| 		}, | ||||
| 		{ | ||||
| 			path: '/search', | ||||
| 			// name: 'edit', | ||||
| 			component: SearchView | ||||
| 		}, | ||||
| 		// { | ||||
| 		//     path: '/view/:id', | ||||
| 		//     name: 'view', | ||||
| 		//     component: StatsView | ||||
| 		// }, | ||||
| 		{ | ||||
| 			path: '/view/:id', | ||||
| 			component: MessageView | ||||
| 		}, | ||||
| 		{ | ||||
| 			path: '/:pathMatch(.*)*', | ||||
| 			name: 'NotFound', | ||||
|   | ||||
| @@ -6,27 +6,31 @@ import Tinycon from 'tinycon' | ||||
| Tinycon.setOptions({ | ||||
| 	height: 11, | ||||
| 	background: '#dd0000', | ||||
| 	fallback: false | ||||
| 	fallback: false, | ||||
| 	font: '9px arial', | ||||
| }) | ||||
|  | ||||
| // global mailbox info | ||||
| export const mailbox = reactive({ | ||||
| 	total: 0, 				// total number of messages | ||||
| 	unread: 0, 				// total unread | ||||
| 	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 | ||||
| 	showTagColors: false, 	// show tag colors? | ||||
| 	selected: [], 			// currently selected | ||||
| 	connected: false, 		// websocket connection | ||||
| 	searching: false,		// whether we are currently searching | ||||
| 	searching: false,		// current search, false for none | ||||
| 	refresh: false, 		// to listen from MessagesMixin | ||||
| 	notificationsSupported: false, | ||||
| 	notificationsEnabled: false, | ||||
| 	appInfo: {},			// application information | ||||
| 	uiConfig: {},			// configuration for UI | ||||
| 	lastMessage: false,		// return scrolling | ||||
| }) | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.total, | ||||
| 	() => mailbox.unread, | ||||
| 	(v) => { | ||||
| 		if (v == 0) { | ||||
| 			Tinycon.reset() | ||||
| @@ -36,6 +40,13 @@ watch( | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.count, | ||||
| 	(v) => { | ||||
| 		mailbox.selected = [] | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| watch( | ||||
| 	() => mailbox.showTagColors, | ||||
| 	(v) => { | ||||
|   | ||||
| @@ -5,8 +5,4 @@ export const pagination = reactive({ | ||||
| 	limit: 50, 	// per page | ||||
| 	total: 0,  	// total results of current view / filter | ||||
| 	count: 0, 	// number of messages currently displayed | ||||
|  | ||||
| 	// increment() { | ||||
| 	//     this.count++ | ||||
| 	// } | ||||
| }) | ||||
|   | ||||
| @@ -1,16 +1,14 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
| import MessagesMixins from '../mixins/MessagesMixins.js' | ||||
|  | ||||
| import AboutMailpit from "../components/AboutMailpit.vue" | ||||
| import AboutMailpit from '../components/AboutMailpit.vue' | ||||
| import AjaxLoader from '../components/AjaxLoader.vue' | ||||
| import ListMessages from "../components/ListMessages.vue" | ||||
| import MailboxActions from "../components/MailboxActions.vue" | ||||
| import MailboxTags from "../components/MailboxTags.vue" | ||||
| import Pagination from "../components/Pagination.vue" | ||||
| import SearchForm from "../components/SearchForm.vue" | ||||
|  | ||||
| import { mailbox } from "../stores/mailbox" | ||||
| 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' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins, MessagesMixins], | ||||
| @@ -19,8 +17,8 @@ export default { | ||||
| 		AboutMailpit, | ||||
| 		AjaxLoader, | ||||
| 		ListMessages, | ||||
| 		MailboxActions, | ||||
| 		MailboxTags, | ||||
| 		NavMailbox, | ||||
| 		NavTags, | ||||
| 		Pagination, | ||||
| 		SearchForm, | ||||
| 	}, | ||||
| @@ -31,54 +29,66 @@ export default { | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		// 'mailbox.total'(v, old) { | ||||
| 		// 	console.log(mailbox.total) | ||||
| 		// } | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.mailbox.searching = false | ||||
| 		this.apiURI = this.$router.resolve(`/api/v1/messages`).href | ||||
| 		mailbox.searching = false | ||||
| 		this.apiURI = this.resolve(`/api/v1/messages`) | ||||
| 		this.loadMessages() | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
|  | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white"> | ||||
| 		<div class="col-lg-2 col-md-3 d-none d-md-block"> | ||||
| 			<RouterLink to="/" class="navbar-brand text-white" @click="reloadMailbox"> | ||||
| 				<img :src="baseURL + 'mailpit.svg'" alt="Mailpit"> | ||||
| 				<span class="ms-2">Mailpit</span> | ||||
| 		<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"> | ||||
| 				<span class="ms-2 d-none d-sm-inline">Mailpit</span> | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
| 		<div class="col col-md-9 col-lg-5"> | ||||
| 		<div class="col col-md-4k col-lg-5 col-xl-6"> | ||||
| 			<SearchForm /> | ||||
| 		</div> | ||||
| 		<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0"> | ||||
| 			<Pagination @loadMessages="loadMessages" :total="mailbox.count" /> | ||||
| 		<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"> | ||||
| 					<i class="bi bi-list"></i> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 			<Pagination @loadMessages="loadMessages" :total="mailbox.total" /> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas" | ||||
| 		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> | ||||
| 		</div> | ||||
| 		<div class="offcanvas-body"> | ||||
| 			<NavMailbox @loadMessages="loadMessages" /> | ||||
| 			<NavTags /> | ||||
| 			<AboutMailpit /> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="row flex-fill" style="min-height:0"> | ||||
| 		<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative" | ||||
| 		<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative" | ||||
| 			style="overflow-y: auto; overflow-x: hidden;"> | ||||
| 			<MailboxActions @loadMessages="loadMessages" /> | ||||
| 			<MailboxTags /> | ||||
| 			<NavMailbox @loadMessages="loadMessages" /> | ||||
| 			<NavTags /> | ||||
| 			<AboutMailpit /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="col-lg-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0"> | ||||
| 		<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0"> | ||||
| 			<div class="mh-100" style="overflow-y: auto;" id="message-page"> | ||||
| 				<ListMessages /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<NavMailbox @loadMessages="loadMessages" modals /> | ||||
| 	<AboutMailpit modals /> | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
|   | ||||
							
								
								
									
										323
									
								
								server/ui-src/views/MessageView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								server/ui-src/views/MessageView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,323 @@ | ||||
| <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' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		AboutMailpit, | ||||
| 		AjaxLoader, | ||||
| 		Message, | ||||
| 		Screenshot, | ||||
| 		Release, | ||||
| 	}, | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			mailbox, | ||||
| 			pagination, | ||||
| 			message: false, | ||||
| 			prevLink: false, | ||||
| 			nextLink: false, | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		$route(to, from) { | ||||
| 			this.loadMessage() | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.loadMessage() | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		loadMessage: function () { | ||||
| 			let self = this | ||||
| 			this.message = false | ||||
| 			let uri = self.resolve('/api/v1/message/' + this.$route.params.id) | ||||
| 			self.get(uri, false, function (response) { | ||||
| 				let d = response.data | ||||
|  | ||||
| 				if (self.wasUnread(d.ID)) { | ||||
| 					mailbox.unread-- | ||||
| 				} | ||||
|  | ||||
| 				// 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' + self.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' + self.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' + self.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' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3' | ||||
| 							) | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				self.message = d | ||||
|  | ||||
| 				self.detectPrevNext() | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		// try detect whether this message was unread based on messages listing | ||||
| 		wasUnread: function (id) { | ||||
| 			for (let m in mailbox.messages) { | ||||
| 				if (mailbox.messages[m].ID == id) { | ||||
| 					if (!mailbox.messages[m].Read) { | ||||
| 						mailbox.messages[m].Read = true | ||||
| 						return true | ||||
| 					} | ||||
| 					return false | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		detectPrevNext: function () { | ||||
| 			// generate the prev/next links based on current message list | ||||
| 			this.prevLink = false | ||||
| 			this.nextLink = false | ||||
| 			let found = false | ||||
|  | ||||
| 			for (let m in mailbox.messages) { | ||||
| 				if (mailbox.messages[m].ID == this.message.ID) { | ||||
| 					found = true | ||||
| 				} else if (found && !this.nextLink) { | ||||
| 					this.nextLink = mailbox.messages[m].ID | ||||
| 					break | ||||
| 				} else { | ||||
| 					this.prevLink = mailbox.messages[m].ID | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		downloadMessageBody: function (str, ext) { | ||||
| 			let dl = document.createElement('a') | ||||
| 			dl.href = "data:text/plain," + encodeURIComponent(str) | ||||
| 			dl.target = '_blank' | ||||
| 			dl.download = this.message.ID + '.' + ext | ||||
| 			dl.click() | ||||
| 		}, | ||||
|  | ||||
| 		screenshotMessageHTML: function () { | ||||
| 			this.$refs.ScreenshotRef.initScreenshot() | ||||
| 		}, | ||||
|  | ||||
| 		// mark current message as read | ||||
| 		markUnread: function () { | ||||
| 			let self = this | ||||
| 			if (!self.message) { | ||||
| 				return false | ||||
| 			} | ||||
| 			let uri = self.resolve('/api/v1/messages') | ||||
| 			self.put(uri, { 'read': false, 'ids': [self.message.ID] }, function (response) { | ||||
| 				self.goBack() | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		deleteMessage: function () { | ||||
| 			let self = this | ||||
| 			let ids = [self.message.ID] | ||||
| 			let uri = self.resolve('/api/v1/messages') | ||||
| 			self.delete(uri, { 'ids': ids }, function () { | ||||
| 				self.goBack() | ||||
| 			}) | ||||
| 		}, | ||||
|  | ||||
| 		goBack: function () { | ||||
| 			mailbox.lastMessage = this.$route.params.id | ||||
|  | ||||
| 			if (mailbox.searching) { | ||||
| 				this.$router.push('/search?q=' + encodeURIComponent(mailbox.searching)) | ||||
| 			} else { | ||||
| 				this.$router.push('/') | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		initReleaseModal: function () { | ||||
| 			let self = this | ||||
| 			self.modal('ReleaseModal').show() | ||||
| 			window.setTimeout(function () { | ||||
| 				window.setTimeout(function () { | ||||
| 					// delay to allow elements to load / focus | ||||
| 					self.$refs.ReleaseRef.initTags() | ||||
| 					document.querySelector('#ReleaseModal input[role="combobox"]').focus() | ||||
| 				}, 500) | ||||
| 			}, 300) | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white"> | ||||
| 		<div class="d-none d-md-block 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"> | ||||
| 				<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"> | ||||
| 			<button @click="goBack()" class="btn btn-outline-light me-4 d-md-none" title="Return to messages"> | ||||
| 				<i class="bi bi-arrow-return-left"></i> | ||||
| 			</button> | ||||
| 			<button class="btn btn-outline-light me-2" title="Mark unread" v-on:click="markUnread"> | ||||
| 				<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span> | ||||
| 			</button> | ||||
| 			<button class="btn btn-outline-light me-2" title="Release message" | ||||
| 				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> | ||||
| 			</button> | ||||
| 			<button class="btn btn-outline-light me-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> | ||||
| 		</div> | ||||
| 		<div class="col-auto col-lg-4 col-xl-4 text-end"> | ||||
|  | ||||
| 			<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"> | ||||
| 					<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"> | ||||
| 							Raw message | ||||
| 						</a> | ||||
| 					</li> | ||||
| 					<li v-if="message.HTML"> | ||||
| 						<button v-on:click="downloadMessageBody(message.HTML, 'html')" class="dropdown-item"> | ||||
| 							HTML body | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<li v-if="message.HTML"> | ||||
| 						<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"> | ||||
| 							Text body | ||||
| 						</button> | ||||
| 					</li> | ||||
| 					<template v-if="allAttachments(message).length"> | ||||
| 						<li> | ||||
| 							<hr class="dropdown-divider"> | ||||
| 						</li> | ||||
| 						<li> | ||||
| 							<h6 class="dropdown-header"> | ||||
| 								Attachment<template v-if="allAttachments(message).length > 1">s</template> | ||||
| 							</h6> | ||||
| 						</li> | ||||
| 						<li v-for="part in allAttachments(message)"> | ||||
| 							<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 ]' }} | ||||
| 								</div> | ||||
| 								<div class="col-auto text-muted small p-0"> | ||||
| 									{{ getFileSize(part.Size) }} | ||||
| 								</div> | ||||
| 							</RouterLink> | ||||
| 						</li> | ||||
| 					</template> | ||||
| 				</ul> | ||||
| 			</div> | ||||
|  | ||||
| 			<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-2 me-1" | ||||
| 				:class="prevLink ? '' : 'disabled'" title="View previous message"> | ||||
| 				<i class="bi bi-caret-left-fill"></i> | ||||
| 			</RouterLink> | ||||
| 			<RouterLink :to="'/view/' + nextLink" class="btn btn-outline-light" :class="nextLink ? '' : 'disabled'"> | ||||
| 				<i class="bi bi-caret-right-fill" title="View next message"></i> | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="row flex-fill" style="min-height:0"> | ||||
| 		<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative" | ||||
| 			style="overflow-y: auto; overflow-x: hidden;"> | ||||
|  | ||||
| 			<div class="list-group my-2"> | ||||
| 				<button @click="goBack()" class="list-group-item list-group-item-action"> | ||||
| 					<i class="bi bi-arrow-return-left me-1"></i> | ||||
| 					<span class="ms-1">Return</span> | ||||
| 					<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages" | ||||
| 						v-if="mailbox.unread"> | ||||
| 						{{ formatNumber(mailbox.unread) }} | ||||
| 					</span> | ||||
| 				</button> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="card mt-4"> | ||||
| 				<div class="card-body text-body-secondary small"> | ||||
| 					<p class="card-text"> | ||||
| 						<b>Message date:</b><br> | ||||
| 						<small>{{ messageDate(message.Date) }}</small> | ||||
| 					</p> | ||||
| 					<p class="card-text"> | ||||
| 						<b>Size:</b> {{ getFileSize(message.Size) }} | ||||
| 					</p> | ||||
| 					<p class="card-text" v-if="allAttachments(message).length"> | ||||
| 						<b>Attachments:</b> {{ allAttachments(message).length }} | ||||
| 					</p> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<AboutMailpit /> | ||||
| 		</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"> | ||||
| 				<Message v-if="message" :key="message.ID" :message="message" /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<AboutMailpit modals /> | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| 	<Release v-if="message" ref="ReleaseRef" :message="message" /> | ||||
| 	<Screenshot v-if="message" ref="ScreenshotRef" :message="message" /> | ||||
| </template> | ||||
| @@ -1,7 +1,29 @@ | ||||
| <script> | ||||
| import AboutMailpit from '../components/AboutMailpit.vue' | ||||
| import CommonMixins from '../mixins/CommonMixins' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins], | ||||
|  | ||||
| 	components: { | ||||
| 		AboutMailpit, | ||||
| 	}, | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| 	<p>Page not found</p> | ||||
| 	<p>Click <router-link to="/">here</router-link> to continue.</p> | ||||
| 	<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;"> | ||||
| 				<p class="h2 my-3">Page not found</p> | ||||
|  | ||||
| 				<p>Click here to continue</p> | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="d-none"> | ||||
| 			<AboutMailpit /> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </template> | ||||
|   | ||||
| @@ -1,17 +1,15 @@ | ||||
| <script> | ||||
| import CommonMixins from '../mixins/CommonMixins.js' | ||||
| import MessagesMixins from '../mixins/MessagesMixins.js' | ||||
|  | ||||
| import AboutMailpit from "../components/AboutMailpit.vue" | ||||
| import AboutMailpit from '../components/AboutMailpit.vue' | ||||
| import AjaxLoader from '../components/AjaxLoader.vue' | ||||
| import ListMessages from "../components/ListMessages.vue" | ||||
| import SearchActions from "../components/SearchActions.vue" | ||||
| import MailboxTags from "../components/MailboxTags.vue" | ||||
| import Pagination from "../components/Pagination.vue" | ||||
| import SearchForm from "../components/SearchForm.vue" | ||||
|  | ||||
| import { mailbox } from "../stores/mailbox" | ||||
| import { pagination } from "../stores/pagination" | ||||
| 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' | ||||
|  | ||||
| export default { | ||||
| 	mixins: [CommonMixins, MessagesMixins], | ||||
| @@ -20,8 +18,8 @@ export default { | ||||
| 		AboutMailpit, | ||||
| 		AjaxLoader, | ||||
| 		ListMessages, | ||||
| 		SearchActions, | ||||
| 		MailboxTags, | ||||
| 		NavSearch, | ||||
| 		NavTags, | ||||
| 		Pagination, | ||||
| 		SearchForm, | ||||
| 	}, | ||||
| @@ -40,25 +38,27 @@ export default { | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		this.mailbox.searching = true | ||||
| 		mailbox.searching = this.getSearch() | ||||
| 		this.doSearch(false) | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		doSearch: function (resetPagination) { | ||||
| 			const urlParams = new URLSearchParams(window.location.search); | ||||
| 			let s = urlParams.get('q') ? urlParams.get('q') : ''; | ||||
| 			let s = this.getSearch() | ||||
|  | ||||
| 			if (s == '') { | ||||
| 			if (!s) { | ||||
| 				mailbox.searching = false | ||||
| 				this.$router.push('/') | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			mailbox.searching = s | ||||
|  | ||||
| 			if (resetPagination) { | ||||
| 				pagination.start = 0 | ||||
| 			} | ||||
|  | ||||
| 			this.apiURI = this.$router.resolve(`/api/v1/search`).href + '?query=' + encodeURIComponent(s) | ||||
| 			this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s) | ||||
| 			this.loadMessages() | ||||
| 		} | ||||
| 	} | ||||
| @@ -67,25 +67,45 @@ export default { | ||||
|  | ||||
| <template> | ||||
| 	<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white"> | ||||
| 		<div class="col-lg-2 col-md-3 d-none d-md-block"> | ||||
| 			<RouterLink to="/" class="navbar-brand text-white"> | ||||
| 				<img :src="baseURL + 'mailpit.svg'" alt=" Mailpit"> | ||||
| 				<span class="ms-2">Mailpit</span> | ||||
| 		<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"> | ||||
| 				<span class="ms-2 d-none d-sm-inline">Mailpit</span> | ||||
| 			</RouterLink> | ||||
| 		</div> | ||||
| 		<div class="col col-md-9 col-lg-5"> | ||||
| 		<div class="col col-md-4k col-lg-5 col-xl-6"> | ||||
| 			<SearchForm /> | ||||
| 		</div> | ||||
| 		<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0"> | ||||
| 		<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0"> | ||||
| 			<div class="float-start d-md-none"> | ||||
| 				<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> | ||||
| 			<Pagination @loadMessages="loadMessages" :total="mailbox.count" /> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas" | ||||
| 		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> | ||||
| 		</div> | ||||
| 		<div class="offcanvas-body"> | ||||
| 			<NavSearch @loadMessages="loadMessages" /> | ||||
| 			<NavTags /> | ||||
| 			<AboutMailpit /> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="row flex-fill" style="min-height:0"> | ||||
| 		<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative" | ||||
| 			style="overflow-y: auto; overflow-x: hidden;"> | ||||
| 			<SearchActions @loadMessages="loadMessages" /> | ||||
| 			<MailboxTags /> | ||||
| 			<NavSearch @loadMessages="loadMessages" /> | ||||
| 			<NavTags /> | ||||
| 			<AboutMailpit /> | ||||
| 		</div> | ||||
|  | ||||
| @@ -96,5 +116,7 @@ export default { | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| 	<NavSearch @loadMessages="loadMessages" modals /> | ||||
| 	<AboutMailpit modals /> | ||||
| 	<AjaxLoader :loading="loading" /> | ||||
| </template> | ||||
|   | ||||
| @@ -532,9 +532,64 @@ | ||||
|             "$ref": "#/responses/ErrorResponse" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "delete": { | ||||
|         "description": "Deletes messages matching a search.", | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "schemes": [ | ||||
|           "http", | ||||
|           "https" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "messages" | ||||
|         ], | ||||
|         "summary": "Delete messages by search", | ||||
|         "operationId": "MessagesSummary", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "Search query", | ||||
|             "name": "query", | ||||
|             "in": "query", | ||||
|             "required": true | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "$ref": "#/responses/OKResponse" | ||||
|           }, | ||||
|           "default": { | ||||
|             "$ref": "#/responses/ErrorResponse" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/api/v1/tags": { | ||||
|       "get": { | ||||
|         "description": "Returns a JSON array of all unique message tags.", | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "schemes": [ | ||||
|           "http", | ||||
|           "https" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "tags" | ||||
|         ], | ||||
|         "summary": "Get all current tags", | ||||
|         "operationId": "SetTags", | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "$ref": "#/responses/ArrayResponse" | ||||
|           }, | ||||
|           "default": { | ||||
|             "$ref": "#/responses/ErrorResponse" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "put": { | ||||
|         "description": "To remove all tags from a message, pass an empty tags array.", | ||||
|         "consumes": [ | ||||
| @@ -964,10 +1019,6 @@ | ||||
|           "description": "Message ID", | ||||
|           "type": "string" | ||||
|         }, | ||||
|         "Read": { | ||||
|           "description": "Read status", | ||||
|           "type": "boolean" | ||||
|         }, | ||||
|         "ReplyTo": { | ||||
|           "description": "ReplyTo addresses", | ||||
|           "type": "array", | ||||
| @@ -1234,6 +1285,15 @@ | ||||
|     } | ||||
|   }, | ||||
|   "responses": { | ||||
|     "ArrayResponse": { | ||||
|       "description": "Plain JSON array response", | ||||
|       "schema": { | ||||
|         "type": "array", | ||||
|         "items": { | ||||
|           "type": "string" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "BinaryResponse": { | ||||
|       "description": "Binary data response inherits the attachment's content type" | ||||
|     }, | ||||
|   | ||||
| @@ -316,6 +316,8 @@ func Store(body []byte) (string, error) { | ||||
|  | ||||
| 	dbLastAction = time.Now() | ||||
|  | ||||
| 	BroadcastMailboxStats() | ||||
|  | ||||
| 	return id, nil | ||||
| } | ||||
|  | ||||
| @@ -556,6 +558,8 @@ func MarkRead(id string) error { | ||||
| 		logger.Log().Debugf("[db] marked message %s as read", id) | ||||
| 	} | ||||
|  | ||||
| 	BroadcastMailboxStats() | ||||
|  | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| @@ -577,6 +581,8 @@ func MarkAllRead() error { | ||||
| 	elapsed := time.Since(start) | ||||
| 	logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed) | ||||
|  | ||||
| 	BroadcastMailboxStats() | ||||
|  | ||||
| 	dbLastAction = time.Now() | ||||
|  | ||||
| 	return nil | ||||
| @@ -600,6 +606,8 @@ func MarkAllUnread() error { | ||||
| 	elapsed := time.Since(start) | ||||
| 	logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed) | ||||
|  | ||||
| 	BroadcastMailboxStats() | ||||
|  | ||||
| 	dbLastAction = time.Now() | ||||
|  | ||||
| 	return nil | ||||
| @@ -622,6 +630,8 @@ func MarkUnread(id string) error { | ||||
|  | ||||
| 	dbLastAction = time.Now() | ||||
|  | ||||
| 	BroadcastMailboxStats() | ||||
|  | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| @@ -656,6 +666,8 @@ func DeleteOneMessage(id string) error { | ||||
| 	dbLastAction = time.Now() | ||||
| 	dbDataDeleted = true | ||||
|  | ||||
| 	BroadcastMailboxStats() | ||||
|  | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| @@ -704,6 +716,7 @@ func DeleteAllMessages() error { | ||||
| 	dbDataDeleted = false | ||||
|  | ||||
| 	websockets.Broadcast("prune", nil) | ||||
| 	BroadcastMailboxStats() | ||||
|  | ||||
| 	return err | ||||
| } | ||||
|   | ||||
							
								
								
									
										35
									
								
								storage/notifications.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								storage/notifications.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| package storage | ||||
|  | ||||
| import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/axllent/mailpit/server/websockets" | ||||
| ) | ||||
|  | ||||
| var bcStatsDelay = false | ||||
|  | ||||
| // BroadcastMailboxStats broadcasts the total number of messages | ||||
| // displayed to the web UI, as well as the total unread messages. | ||||
| // The lookup is very fast (< 10ms / 100k messages under load). | ||||
| // Rate limited to 4x per second. | ||||
| func BroadcastMailboxStats() { | ||||
| 	if bcStatsDelay { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	bcStatsDelay = true | ||||
|  | ||||
| 	go func() { | ||||
| 		time.Sleep(250 * time.Millisecond) | ||||
| 		bcStatsDelay = false | ||||
| 		b := struct { | ||||
| 			Total  int | ||||
| 			Unread int | ||||
| 		}{ | ||||
| 			Total:  CountTotal(), | ||||
| 			Unread: CountUnread(), | ||||
| 		} | ||||
|  | ||||
| 		websockets.Broadcast("stats", b) | ||||
| 	}() | ||||
| } | ||||
		Reference in New Issue
	
	Block a user