mirror of
https://github.com/axllent/mailpit.git
synced 2025-02-03 13:12:03 +02:00
parent
e18c45d0b3
commit
ee49149df9
22
package-lock.json
generated
22
package-lock.json
generated
@ -19,7 +19,8 @@
|
|||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
"tinycon": "^0.6.8",
|
"tinycon": "^0.6.8",
|
||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
"vue-css-donut-chart": "^2.0.0"
|
"vue-css-donut-chart": "^2.0.0",
|
||||||
|
"vue-router": "^4.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@popperjs/core": "^2.11.5",
|
"@popperjs/core": "^2.11.5",
|
||||||
@ -829,6 +830,11 @@
|
|||||||
"@vue/shared": "3.3.4"
|
"@vue/shared": "3.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/devtools-api": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
|
||||||
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz",
|
||||||
@ -2413,6 +2419,20 @@
|
|||||||
"vue": "^3"
|
"vue": "^3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-router": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-9PISkmaCO02OzPVOMq2w82ilty6+xJmQrarYZDkjZBfl4RvYAlt4PKnEX21oW4KTtWfa9OuO/b3qk1Od3AEdCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "4.0.0-beta.3",
|
"version": "4.0.0-beta.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||||
|
@ -20,7 +20,8 @@
|
|||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
"tinycon": "^0.6.8",
|
"tinycon": "^0.6.8",
|
||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
"vue-css-donut-chart": "^2.0.0"
|
"vue-css-donut-chart": "^2.0.0",
|
||||||
|
"vue-router": "^4.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@popperjs/core": "^2.11.5",
|
"@popperjs/core": "^2.11.5",
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
"github.com/axllent/mailpit/config"
|
"github.com/axllent/mailpit/config"
|
||||||
"github.com/axllent/mailpit/server/apiv1"
|
"github.com/axllent/mailpit/server/apiv1"
|
||||||
@ -42,7 +43,7 @@ func Listen() {
|
|||||||
|
|
||||||
go websockets.MessageHub.Run()
|
go websockets.MessageHub.Run()
|
||||||
|
|
||||||
r := defaultRoutes()
|
r := apiRoutes()
|
||||||
|
|
||||||
// kubernetes probes
|
// kubernetes probes
|
||||||
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
|
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
|
||||||
@ -51,18 +52,24 @@ func Listen() {
|
|||||||
// proxy handler for screenshots
|
// proxy handler for screenshots
|
||||||
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
||||||
|
|
||||||
// web UI websocket
|
// virtual filesystem for /dist/ & some individual files
|
||||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||||
|
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||||
// virtual filesystem for others
|
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||||
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||||
|
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||||
|
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||||
|
|
||||||
// redirect to webroot if no trailing slash
|
// redirect to webroot if no trailing slash
|
||||||
if config.Webroot != "/" {
|
if config.Webroot != "/" {
|
||||||
redir := strings.TrimRight(config.Webroot, "/")
|
redirect := strings.TrimRight(config.Webroot, "/")
|
||||||
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
|
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle everything else with the virtual index.html
|
||||||
|
r.PathPrefix(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
|
||||||
|
|
||||||
|
// put it all together
|
||||||
http.Handle("/", r)
|
http.Handle("/", r)
|
||||||
|
|
||||||
if config.UIAuthFile != "" {
|
if config.UIAuthFile != "" {
|
||||||
@ -81,7 +88,7 @@ func Listen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultRoutes() *mux.Router {
|
func apiRoutes() *mux.Router {
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
// API V1
|
// API V1
|
||||||
@ -104,6 +111,9 @@ func defaultRoutes() *mux.Router {
|
|||||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||||
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
|
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
|
||||||
|
|
||||||
|
// web UI websocket
|
||||||
|
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||||
|
|
||||||
// return blank 200 response for OPTIONS requests for CORS
|
// return blank 200 response for OPTIONS requests for CORS
|
||||||
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
|
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
|
||||||
|
|
||||||
@ -230,3 +240,59 @@ func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
|||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
_, _ = w.Write(f)
|
_, _ = w.Write(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Just returns the default HTML template
|
||||||
|
func index(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|
||||||
|
var h = `<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-100">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||||
|
<link rel="icon" href="{{ .Webroot }}favicon.svg">
|
||||||
|
<title>Mailpit</title>
|
||||||
|
<link rel=stylesheet href="{{ .Webroot }}dist/app.css?{{ .Version }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="h-100">
|
||||||
|
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}">
|
||||||
|
<noscript>You require JavaScript to use this app.</noscript>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
t, err := template.New("index").Parse(h)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Webroot string
|
||||||
|
Version string
|
||||||
|
}{
|
||||||
|
Webroot: config.Webroot,
|
||||||
|
Version: config.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
buff := new(bytes.Buffer)
|
||||||
|
|
||||||
|
err = t.Execute(buff, data)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buff.Bytes()
|
||||||
|
|
||||||
|
// f, err := embeddedFS.ReadFile("public/index.html")
|
||||||
|
// if err != nil {
|
||||||
|
// panic(err)
|
||||||
|
// }
|
||||||
|
w.Header().Add("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write(buff.Bytes())
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@ -29,7 +29,7 @@ func Test_APIv1(t *testing.T) {
|
|||||||
setup()
|
setup()
|
||||||
defer storage.Close()
|
defer storage.Close()
|
||||||
|
|
||||||
r := defaultRoutes()
|
r := apiRoutes()
|
||||||
|
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@ -57,8 +57,8 @@ func Test_APIv1(t *testing.T) {
|
|||||||
// read first 10
|
// read first 10
|
||||||
t.Log("Read first 10 messages including raw & headers")
|
t.Log("Read first 10 messages including raw & headers")
|
||||||
putIDS := []string{}
|
putIDS := []string{}
|
||||||
for indx, msg := range m.Messages {
|
for idx, msg := range m.Messages {
|
||||||
if indx == 10 {
|
if idx == 10 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,7 +253,7 @@ func clientGet(url string) ([]byte, error) {
|
|||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
data, err := ioutil.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
@ -278,7 +278,7 @@ func clientDelete(url, body string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := ioutil.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
@ -303,7 +303,7 @@ func clientPut(url, body string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := ioutil.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,13 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue';
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
import "./assets/styles.scss";
|
import "./assets/styles.scss";
|
||||||
import "bootstrap-icons/font/bootstrap-icons.scss";
|
import "bootstrap-icons/font/bootstrap-icons.scss";
|
||||||
import "bootstrap";
|
import "bootstrap";
|
||||||
|
|
||||||
createApp(App).mount('#app');
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
@ -1,373 +1,369 @@
|
|||||||
@import "./bootstrap";
|
@import "./bootstrap";
|
||||||
|
|
||||||
[v-cloak] {
|
[v-cloak] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
color: #2d4a5d;
|
color: #2d4a5d;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
@include media-breakpoint-down(md) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 35px;
|
width: 35px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
span {
|
span {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: all 0.5s;
|
transition: all 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
span {
|
span {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs .nav-link {
|
.nav-tabs .nav-link {
|
||||||
@include media-breakpoint-down(xl) {
|
@include media-breakpoint-down(xl) {
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:not(.text-view) > a:not(.no-icon) {
|
:not(.text-view) > a:not(.no-icon) {
|
||||||
&[href^="http://"],
|
&[href^="http://"],
|
||||||
&[href^="https://"]
|
&[href^="https://"]
|
||||||
{
|
{
|
||||||
&:after {
|
&:after {
|
||||||
content: "\f1c5";
|
content: "\f1c5";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-family: "bootstrap-icons" !important;
|
font-family: "bootstrap-icons" !important;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: normal !important;
|
font-weight: normal !important;
|
||||||
font-variant: normal;
|
font-variant: normal;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
vertical-align: -0.125em;
|
vertical-align: -0.125em;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#loading {
|
.loader {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
left: 0;
|
||||||
bottom: 0;
|
width: 100%;
|
||||||
left: 0;
|
height: 100%;
|
||||||
background: rgba(255, 255, 255, 0.4);
|
background: rgba(255, 255, 255, 0.4);
|
||||||
z-index: 1500;
|
z-index: 1500;
|
||||||
}
|
}
|
||||||
|
|
||||||
// dark mode adjustments
|
// dark mode adjustments
|
||||||
@include color-mode(dark) {
|
@include color-mode(dark) {
|
||||||
#loading {
|
.loader {
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.tag,
|
.token.tag,
|
||||||
.token.property {
|
.token.property {
|
||||||
color: #ee6969;
|
color: #ee6969;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
&.read {
|
&.read {
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
|
|
||||||
b {
|
b {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.selected {
|
&.selected {
|
||||||
background: var(--bs-primary-bg-subtle);
|
background: var(--bs-primary-bg-subtle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-plain-text .text-view,
|
#nav-plain-text .text-view,
|
||||||
#nav-source {
|
#nav-source {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-family:
|
font-family: "Courier New", Courier, System, fixed-width;
|
||||||
Courier New,
|
font-size: 0.85em;
|
||||||
Courier,
|
|
||||||
System,
|
|
||||||
fixed-width;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-html-source pre[class*="language-"] code {
|
#nav-html-source pre[class*="language-"] code {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-plain-text .text-view {
|
#nav-plain-text .text-view {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messageHeaders {
|
.messageHeaders {
|
||||||
margin: 15px 0 0;
|
margin: 15px 0 0;
|
||||||
|
|
||||||
th {
|
th {
|
||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-html {
|
#nav-html {
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview-html {
|
#preview-html {
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
|
||||||
&.tablet,
|
&.tablet,
|
||||||
&.phone {
|
&.phone {
|
||||||
border: solid $gray-300 1px;
|
border: solid $gray-300 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#responsive-view {
|
#responsive-view {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
transition: width 0.5s;
|
transition: width 0.5s;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.tablet,
|
&.tablet,
|
||||||
&.phone {
|
&.phone {
|
||||||
border-radius: 35px;
|
border-radius: 35px;
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
padding-bottom: 76px;
|
padding-bottom: 76px;
|
||||||
padding-top: 54px;
|
padding-top: 54px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
background: $gray-800;
|
background: $gray-800;
|
||||||
|
|
||||||
iframe {
|
iframe {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.phone {
|
&.phone {
|
||||||
&::before {
|
&::before {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: $gray-600;
|
background: $gray-600;
|
||||||
top: 22px;
|
top: 22px;
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 80px;
|
width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: $gray-900;
|
background: $gray-900;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
width: 65px;
|
width: 65px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tablet {
|
&.tablet {
|
||||||
&::before {
|
&::before {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: solid #b5b0b0 2px;
|
border: solid #b5b0b0 2px;
|
||||||
top: 22px;
|
top: 22px;
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: solid #b5b0b0 2px;
|
border: solid #b5b0b0 2px;
|
||||||
bottom: 23px;
|
bottom: 23px;
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.message:first-child {
|
.list-group-item.message:first-child {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.blur {
|
body.blur {
|
||||||
.privacy {
|
.privacy {
|
||||||
filter: blur(3px);
|
filter: blur(3px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.attachment {
|
.card.attachment {
|
||||||
color: $gray-800;
|
color: $gray-800;
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 18px;
|
top: 18px;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
font-size: 3.5rem;
|
font-size: 3.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: $gray-300;
|
color: $gray-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
background: $gray-300;
|
background: $gray-300;
|
||||||
|
|
||||||
.bi {
|
.bi {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
margin-left: -10px;
|
margin-left: -10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.card-body {
|
.card-body {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: $gray-300;
|
background: $gray-300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-select.tag-selector {
|
.form-select.tag-selector {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control.dropdown {
|
.form-control.dropdown {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
font-size: 0.875em;
|
font-size: 0.875em;
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
div {
|
||||||
cursor: text; // html5-tags
|
cursor: text; // html5-tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrap5-tags
|
// bootstrap5-tags
|
||||||
.tags-badge {
|
.tags-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
#DownloadBtn {
|
#DownloadBtn {
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(sm) {
|
||||||
position: static;
|
position: static;
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#ReleaseModal {
|
#ReleaseModal {
|
||||||
.form-control.dropdown {
|
.form-control.dropdown {
|
||||||
div {
|
div {
|
||||||
@extend .form-control;
|
@extend .form-control;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PrismJS 1.29.0 - modified!
|
/* PrismJS 1.29.0 - modified!
|
||||||
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
|
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
|
||||||
code[class*="language-"],
|
code[class*="language-"],
|
||||||
pre[class*="language-"] {
|
pre[class*="language-"] {
|
||||||
// color: #000;
|
// color: #000;
|
||||||
// background: 0 0;
|
// background: 0 0;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
word-spacing: normal;
|
word-spacing: normal;
|
||||||
word-break: normal;
|
word-break: normal;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-moz-tab-size: 4;
|
-moz-tab-size: 4;
|
||||||
-o-tab-size: 4;
|
-o-tab-size: 4;
|
||||||
tab-size: 4;
|
tab-size: 4;
|
||||||
-webkit-hyphens: none;
|
-webkit-hyphens: none;
|
||||||
-moz-hyphens: none;
|
-moz-hyphens: none;
|
||||||
-ms-hyphens: none;
|
-ms-hyphens: none;
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
}
|
}
|
||||||
pre[class*="language-"] {
|
pre[class*="language-"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
pre[class*="language-"] > code {
|
pre[class*="language-"] > code {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
code[class*="language-"] {
|
code[class*="language-"] {
|
||||||
max-height: inherit;
|
max-height: inherit;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
display: block;
|
display: block;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
:not(pre) > code[class*="language-"],
|
:not(pre) > code[class*="language-"],
|
||||||
pre[class*="language-"] {
|
pre[class*="language-"] {
|
||||||
// background-color: #fdfdfd;
|
// background-color: #fdfdfd;
|
||||||
-webkit-box-sizing: border-box;
|
-webkit-box-sizing: border-box;
|
||||||
-moz-box-sizing: border-box;
|
-moz-box-sizing: border-box;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
:not(pre) > code[class*="language-"] {
|
:not(pre) > code[class*="language-"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0.2em;
|
padding: 0.2em;
|
||||||
border-radius: 0.3em;
|
border-radius: 0.3em;
|
||||||
color: #c92c2c;
|
color: #c92c2c;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
display: inline;
|
display: inline;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.block-comment,
|
.token.block-comment,
|
||||||
@ -375,10 +371,10 @@ pre[class*="language-"] {
|
|||||||
.token.comment,
|
.token.comment,
|
||||||
.token.doctype,
|
.token.doctype,
|
||||||
.token.prolog {
|
.token.prolog {
|
||||||
color: #7d8b99;
|
color: #7d8b99;
|
||||||
}
|
}
|
||||||
.token.punctuation {
|
.token.punctuation {
|
||||||
color: #5f6364;
|
color: #5f6364;
|
||||||
}
|
}
|
||||||
.token.boolean,
|
.token.boolean,
|
||||||
.token.constant,
|
.token.constant,
|
||||||
@ -388,7 +384,7 @@ pre[class*="language-"] {
|
|||||||
.token.property,
|
.token.property,
|
||||||
.token.symbol,
|
.token.symbol,
|
||||||
.token.tag {
|
.token.tag {
|
||||||
color: #c92c2c;
|
color: #c92c2c;
|
||||||
}
|
}
|
||||||
.token.attr-name,
|
.token.attr-name,
|
||||||
.token.builtin,
|
.token.builtin,
|
||||||
@ -397,70 +393,70 @@ pre[class*="language-"] {
|
|||||||
.token.inserted,
|
.token.inserted,
|
||||||
.token.selector,
|
.token.selector,
|
||||||
.token.string {
|
.token.string {
|
||||||
color: #2f9c0a;
|
color: #2f9c0a;
|
||||||
}
|
}
|
||||||
.token.entity,
|
.token.entity,
|
||||||
.token.operator,
|
.token.operator,
|
||||||
.token.url,
|
.token.url,
|
||||||
.token.variable {
|
.token.variable {
|
||||||
color: #a67f59;
|
color: #a67f59;
|
||||||
// background: rgba(255, 255, 255, 0.5);
|
// background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
.token.atrule,
|
.token.atrule,
|
||||||
.token.attr-value,
|
.token.attr-value,
|
||||||
.token.class-name,
|
.token.class-name,
|
||||||
.token.keyword {
|
.token.keyword {
|
||||||
color: #1990b8;
|
color: #1990b8;
|
||||||
}
|
}
|
||||||
.token.important,
|
.token.important,
|
||||||
.token.regex {
|
.token.regex {
|
||||||
color: #e90;
|
color: #e90;
|
||||||
}
|
}
|
||||||
.language-css .token.string,
|
.language-css .token.string,
|
||||||
.style .token.string {
|
.style .token.string {
|
||||||
color: #a67f59;
|
color: #a67f59;
|
||||||
// background: rgba(255, 255, 255, 0.5);
|
// background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
.token.important {
|
.token.important {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.token.bold {
|
.token.bold {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.token.italic {
|
.token.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
// .token.entity {
|
// .token.entity {
|
||||||
// cursor: help;
|
// cursor: help;
|
||||||
// }
|
// }
|
||||||
.token.namespace {
|
.token.namespace {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 767px) {
|
@media screen and (max-width: 767px) {
|
||||||
pre[class*="language-"]::after,
|
pre[class*="language-"]::after,
|
||||||
pre[class*="language-"]::before {
|
pre[class*="language-"]::before {
|
||||||
bottom: 14px;
|
bottom: 14px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pre[class*="language-"].line-numbers.line-numbers {
|
pre[class*="language-"].line-numbers.line-numbers {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
pre[class*="language-"].line-numbers.line-numbers code {
|
pre[class*="language-"].line-numbers.line-numbers code {
|
||||||
padding-left: 3.8em;
|
padding-left: 3.8em;
|
||||||
}
|
}
|
||||||
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
|
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
pre[class*="language-"][data-line] {
|
pre[class*="language-"][data-line] {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
pre[data-line] code {
|
pre[data-line] code {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 4em;
|
padding-left: 4em;
|
||||||
}
|
}
|
||||||
pre .line-highlight {
|
pre .line-highlight {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
210
server/ui-src/components/AboutMailpit.vue
Normal file
210
server/ui-src/components/AboutMailpit.vue
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from '../mixins/CommonMixins.js'
|
||||||
|
import AjaxLoader from './AjaxLoader.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
AjaxLoader
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
theme: 'auto',
|
||||||
|
icon: '#circle-half',
|
||||||
|
icons: {
|
||||||
|
'auto': '#circle-half',
|
||||||
|
'light': '#sun-fill',
|
||||||
|
'dark': '#moon-stars-fill'
|
||||||
|
},
|
||||||
|
appInfo: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.setTheme(this.getPreferredTheme())
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
loadInfo: function () {
|
||||||
|
let self = this
|
||||||
|
self.get(this.baseURL + 'api/v1/info', false, function (response) {
|
||||||
|
self.appInfo = response.data
|
||||||
|
self.modal('AppInfoModal').show()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getStoredTheme: function () {
|
||||||
|
let theme = localStorage.getItem('theme')
|
||||||
|
if (!theme) {
|
||||||
|
theme = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme
|
||||||
|
},
|
||||||
|
|
||||||
|
setStoredTheme: function (theme) {
|
||||||
|
localStorage.setItem('theme', theme)
|
||||||
|
this.setTheme(theme)
|
||||||
|
},
|
||||||
|
|
||||||
|
getPreferredTheme: function () {
|
||||||
|
const storedTheme = this.getStoredTheme()
|
||||||
|
if (storedTheme) {
|
||||||
|
return storedTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme: function (theme) {
|
||||||
|
this.icon = this.icons[theme]
|
||||||
|
this.theme = theme
|
||||||
|
if (
|
||||||
|
theme === 'auto' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
) {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AjaxLoader :loading="loading" />
|
||||||
|
</template>
|
16
server/ui-src/components/AjaxLoader.vue
Normal file
16
server/ui-src/components/AjaxLoader.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
loading: Number,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="loader" v-if="loading > 0">
|
||||||
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
|
<div class="spinner-border text-secondary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
119
server/ui-src/components/ListMessages.vue
Normal file
119
server/ui-src/components/ListMessages.vue
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from '../mixins/CommonMixins.js'
|
||||||
|
import { mailbox } from '../stores/mailbox.js'
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [
|
||||||
|
CommonMixins
|
||||||
|
],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mailbox,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
moment.updateLocale('en', {
|
||||||
|
relativeTime: {
|
||||||
|
future: "in %s",
|
||||||
|
past: "%s ago",
|
||||||
|
s: 'seconds',
|
||||||
|
ss: '%d secs',
|
||||||
|
m: "a minute",
|
||||||
|
mm: "%d mins",
|
||||||
|
h: "an hour",
|
||||||
|
hh: "%d hours",
|
||||||
|
d: "a day",
|
||||||
|
dd: "%d days",
|
||||||
|
w: "a week",
|
||||||
|
ww: "%d weeks",
|
||||||
|
M: "a month",
|
||||||
|
MM: "%d months",
|
||||||
|
y: "a year",
|
||||||
|
yy: "%d years"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
getRelativeCreated: function (message) {
|
||||||
|
let d = new Date(message.Created)
|
||||||
|
return moment(d).fromNow().toString()
|
||||||
|
},
|
||||||
|
|
||||||
|
getPrimaryEmailTo: function (message) {
|
||||||
|
for (let i in message.To) {
|
||||||
|
return message.To[i].Address
|
||||||
|
}
|
||||||
|
|
||||||
|
return '[ Undisclosed recipients ]'
|
||||||
|
},
|
||||||
|
|
||||||
|
isSelected: function (id) {
|
||||||
|
return mailbox.selected.indexOf(id) != -1
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||||
|
<div class="list-group my-2">
|
||||||
|
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID"
|
||||||
|
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' : ''"> -->
|
||||||
|
<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>
|
||||||
|
{{ getRelativeCreated(message) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-truncate d-lg-none privacy">
|
||||||
|
<span v-if="message.From" :title="message.From.Address">{{
|
||||||
|
message.From.Name ?
|
||||||
|
message.From.Name : message.From.Address
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-truncate d-none d-lg-block privacy">
|
||||||
|
<b v-if="message.From" :title="message.From.Address">{{
|
||||||
|
message.From.Name ?
|
||||||
|
message.From.Name : message.From.Address
|
||||||
|
}}</b>
|
||||||
|
</div>
|
||||||
|
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||||
|
{{ getPrimaryEmailTo(message) }}
|
||||||
|
<span v-if="message.To && message.To.length > 1">
|
||||||
|
[+{{ message.To.length - 1 }}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||||
|
<div><b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b></div>
|
||||||
|
<div>
|
||||||
|
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="'/search?q=' + tagEncodeURI(t)"
|
||||||
|
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||||
|
:title="'Filter messages tagged with ' + t">
|
||||||
|
{{ t }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||||
|
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||||
|
{{ getFileSize(message.Size) }}
|
||||||
|
</div>
|
||||||
|
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
|
||||||
|
{{ getRelativeCreated(message) }}
|
||||||
|
</div>
|
||||||
|
<!-- </a> -->
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-center mt-5">There are no messages</p>
|
||||||
|
</template>
|
||||||
|
</template>
|
112
server/ui-src/components/MailboxActions.vue
Normal file
112
server/ui-src/components/MailboxActions.vue
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<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>
|
56
server/ui-src/components/MailboxTags.vue
Normal file
56
server/ui-src/components/MailboxTags.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<script>
|
||||||
|
import { mailbox } from '../stores/mailbox.js'
|
||||||
|
import CommonMixins from '../mixins/CommonMixins.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mailbox,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
inSearch: function (tag) {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const query = urlParams.get('q');
|
||||||
|
if (!query) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let re = new RegExp(`\\btag:"?${tag}"?\\b`, 'i');
|
||||||
|
|
||||||
|
return query.match(re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||||
|
<div class="mt-4 text-muted">
|
||||||
|
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Tags
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
|
||||||
|
<template v-if="mailbox.showTagColors">Hide</template>
|
||||||
|
<template v-else>Show</template>
|
||||||
|
tag colors
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="list-group mt-1 mb-5 pb-3">
|
||||||
|
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)"
|
||||||
|
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||||
|
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
||||||
|
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||||
|
<i class="bi bi-tag" v-else></i>
|
||||||
|
{{ tag }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
163
server/ui-src/components/Notifications.vue
Normal file
163
server/ui-src/components/Notifications.vue
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<script>
|
||||||
|
import { mailbox } from "../stores/mailbox.js"
|
||||||
|
import { pagination } from "../stores/pagination.js"
|
||||||
|
import { Toast } from 'bootstrap'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pagination,
|
||||||
|
mailbox,
|
||||||
|
toastMessage: false, //
|
||||||
|
reconnectRefresh: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.connect()
|
||||||
|
|
||||||
|
mailbox.notificationsSupported = window.isSecureContext
|
||||||
|
&& ("Notification" in window && Notification.permission !== "denied")
|
||||||
|
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||||
|
},
|
||||||
|
|
||||||
|
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 self = this
|
||||||
|
ws.onmessage = function (e) {
|
||||||
|
let response = JSON.parse(e.data)
|
||||||
|
if (!response) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// new messages
|
||||||
|
if (response.Type == "new" && response.Data) {
|
||||||
|
if (!mailbox.searching) {
|
||||||
|
if (pagination.start < 1) {
|
||||||
|
// first page
|
||||||
|
mailbox.messages.unshift(response.Data)
|
||||||
|
if (mailbox.messages.length > pagination.limit) {
|
||||||
|
mailbox.messages.pop()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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])
|
||||||
|
mailbox.tags.sort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = function () {
|
||||||
|
mailbox.connected = true
|
||||||
|
if (self.reconnectRefresh) {
|
||||||
|
self.reconnectRefresh = false
|
||||||
|
mailbox.refresh = true // trigger refresh
|
||||||
|
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = function (e) {
|
||||||
|
mailbox.connected = false
|
||||||
|
self.reconnectRefresh = true
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
self.connect() // reconnect
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = function (err) {
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
browserNotify: function (title, message) {
|
||||||
|
if (!("Notification" in window)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
let b = message.Subject
|
||||||
|
let options = {
|
||||||
|
body: message,
|
||||||
|
icon: 'notification.png'
|
||||||
|
}
|
||||||
|
new Notification(title, options)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setMessageToast: function (m) {
|
||||||
|
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||||
|
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toastMessage = m
|
||||||
|
|
||||||
|
let self = this
|
||||||
|
let el = document.getElementById('messageToast')
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('hidden.bs.toast', () => {
|
||||||
|
self.toastMessage = false
|
||||||
|
})
|
||||||
|
|
||||||
|
Toast.getOrCreateInstance(el).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeToast: function () {
|
||||||
|
let el = document.getElementById('messageToast')
|
||||||
|
if (el) {
|
||||||
|
Toast.getOrCreateInstance(el).hide()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="toast-header" v-if="toastMessage">
|
||||||
|
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||||
|
<strong class="me-auto">
|
||||||
|
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||||
|
</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-body">
|
||||||
|
<div>
|
||||||
|
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
|
||||||
|
@click="closeToast">
|
||||||
|
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
|
||||||
|
<template v-else>
|
||||||
|
[ no subject ]
|
||||||
|
</template>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></template>
|
90
server/ui-src/components/Pagination.vue
Normal file
90
server/ui-src/components/Pagination.vue
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from "../mixins/CommonMixins"
|
||||||
|
import { pagination } from "../stores/pagination.js"
|
||||||
|
import { mailbox } from "../stores/mailbox.js"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
total: Number,
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['loadMessages'],
|
||||||
|
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pagination,
|
||||||
|
mailbox,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
canPrev: function () {
|
||||||
|
return this.pagination.start > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
canNext: function () {
|
||||||
|
return this.total > (this.pagination.start + this.mailbox.messages.length)
|
||||||
|
},
|
||||||
|
|
||||||
|
// returns the number of next X messages
|
||||||
|
nextMessages: function () {
|
||||||
|
let t = pagination.start + parseInt(pagination.limit, 10);
|
||||||
|
if (t > this.total) {
|
||||||
|
t = this.total
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
changeLimit: function () {
|
||||||
|
this.pagination.start = 0
|
||||||
|
this.$emit('loadMessages')
|
||||||
|
},
|
||||||
|
|
||||||
|
viewNext: function () {
|
||||||
|
this.pagination.start = parseInt(this.pagination.start, 10) + parseInt(this.pagination.limit, 10)
|
||||||
|
this.$emit('loadMessages')
|
||||||
|
},
|
||||||
|
|
||||||
|
viewPrev: function () {
|
||||||
|
let s = this.pagination.start - this.pagination.limit
|
||||||
|
if (s < 0) {
|
||||||
|
s = 0
|
||||||
|
}
|
||||||
|
this.pagination.start = s
|
||||||
|
this.$emit('loadMessages')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<select v-model="pagination.limit" @change="changeLimit"
|
||||||
|
class="form-select form-select-sm d-none d-md-inline w-auto me-2">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<small>
|
||||||
|
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||||
|
<small>of</small>
|
||||||
|
{{ formatNumber(total) }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||||
|
:title="'View previous ' + pagination.limit + ' messages'">
|
||||||
|
<i class="bi bi-caret-left-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
|
||||||
|
:title="'View next ' + pagination.limit + ' messages'">
|
||||||
|
<i class="bi bi-caret-right-fill"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
47
server/ui-src/components/SearchActions.vue
Normal file
47
server/ui-src/components/SearchActions.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<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>
|
65
server/ui-src/components/SearchForm.vue
Normal file
65
server/ui-src/components/SearchForm.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from '../mixins/CommonMixins.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
search: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.searchFromURL()
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
$route() {
|
||||||
|
this.searchFromURL()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
searchFromURL: function () {
|
||||||
|
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;
|
||||||
|
if (this.search == '') {
|
||||||
|
this.$router.push('/')
|
||||||
|
} else {
|
||||||
|
this.$router.push('/search?q=' + encodeURIComponent(this.search))
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
},
|
||||||
|
|
||||||
|
resetSearch: function () {
|
||||||
|
this.search = ''
|
||||||
|
this.$router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
|
||||||
|
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
|
||||||
|
placeholder="Search mailbox">
|
||||||
|
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''"
|
||||||
|
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
@ -14,17 +14,27 @@ FakeModal.prototype.show = function () { }
|
|||||||
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
|
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
|
||||||
|
|
||||||
/* Common mixin functions used in apps */
|
/* Common mixin functions used in apps */
|
||||||
const commonMixins = {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: 0,
|
loading: 0,
|
||||||
tagColorCache: {},
|
tagColorCache: {},
|
||||||
showTagColors: true
|
// showTagColors: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
beforeMount() {
|
||||||
|
// this.baseURL = this.$router.resolve(`/`).href
|
||||||
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.showTagColors = localStorage.getItem('showTagsColors')
|
// this.showTagColors = localStorage.getItem('showTagsColors')
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
baseURL() {
|
||||||
|
return window.baseURL
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
@ -41,6 +51,14 @@ const commonMixins = {
|
|||||||
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) {
|
||||||
|
if (tag.match(/ /)) {
|
||||||
|
tag = `"${tag}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'tag:' + encodeURIComponent(`${tag}`)
|
||||||
|
},
|
||||||
|
|
||||||
// Ajax error message
|
// Ajax error message
|
||||||
handleError: function (error) {
|
handleError: function (error) {
|
||||||
// handle error
|
// handle error
|
||||||
@ -224,17 +242,17 @@ const commonMixins = {
|
|||||||
return this.tagColorCache[s]
|
return this.tagColorCache[s]
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTagColors: function () {
|
// toggleTagColors: function () {
|
||||||
if (this.showTagColors) {
|
// if (this.showTagColors) {
|
||||||
localStorage.removeItem('showTagsColors')
|
// localStorage.removeItem('showTagsColors')
|
||||||
this.showTagColors = false
|
// this.showTagColors = false
|
||||||
} else {
|
// } else {
|
||||||
localStorage.setItem('showTagsColors', '1')
|
// localStorage.setItem('showTagsColors', '1')
|
||||||
this.showTagColors = true
|
// this.showTagColors = true
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default commonMixins;
|
// export default commonMixins;
|
77
server/ui-src/mixins/MessagesMixins.js
Normal file
77
server/ui-src/mixins/MessagesMixins.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import CommonMixins from './CommonMixins.js'
|
||||||
|
import { mailbox } from "../stores/mailbox.js"
|
||||||
|
import { pagination } from "../stores/pagination.js"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
apiURI: false,
|
||||||
|
pagination,
|
||||||
|
mailbox,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'mailbox.refresh': function (v) {
|
||||||
|
if (v) {
|
||||||
|
// trigger a refresh
|
||||||
|
this.loadMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
mailbox.refresh = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
reloadMailbox: function () {
|
||||||
|
pagination.start = 0;
|
||||||
|
this.loadMessages()
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMessages: function () {
|
||||||
|
if (!this.apiURI) {
|
||||||
|
alert('apiURL not set!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let self = this
|
||||||
|
let params = {}
|
||||||
|
mailbox.selected = []
|
||||||
|
|
||||||
|
params['limit'] = pagination.limit
|
||||||
|
if (pagination.start > 0) {
|
||||||
|
params['start'] = pagination.start
|
||||||
|
}
|
||||||
|
|
||||||
|
self.get(this.apiURI, params, function (response) {
|
||||||
|
mailbox.total = response.data.total // all messages
|
||||||
|
mailbox.unread = response.data.unread // all unread messages
|
||||||
|
mailbox.tags = response.data.tags // all tags
|
||||||
|
mailbox.messages = response.data.messages // current messages
|
||||||
|
mailbox.count = response.data.messages_count // total results for this mailbox/search
|
||||||
|
// 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) {
|
||||||
|
let mp = document.getElementById('message-page')
|
||||||
|
if (mp) {
|
||||||
|
mp.scrollTop = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollInPlace = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
42
server/ui-src/router/index.js
Normal file
42
server/ui-src/router/index.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import MailboxView from '../views/MailboxView.vue'
|
||||||
|
import SearchView from '../views/SearchView.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'
|
||||||
|
|
||||||
|
let d = document.getElementById('app')
|
||||||
|
let webroot = '/'
|
||||||
|
if (d) {
|
||||||
|
webroot = d.dataset.webroot
|
||||||
|
}
|
||||||
|
|
||||||
|
// paths are relative to webroot
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(webroot),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
// name: 'home',
|
||||||
|
component: MailboxView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search',
|
||||||
|
// name: 'edit',
|
||||||
|
component: SearchView
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// path: '/view/:id',
|
||||||
|
// name: 'view',
|
||||||
|
// component: StatsView
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: NotFoundView
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
48
server/ui-src/stores/mailbox.js
Normal file
48
server/ui-src/stores/mailbox.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// State Management
|
||||||
|
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import Tinycon from 'tinycon'
|
||||||
|
|
||||||
|
Tinycon.setOptions({
|
||||||
|
height: 11,
|
||||||
|
background: '#dd0000',
|
||||||
|
fallback: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// global mailbox info
|
||||||
|
export const mailbox = reactive({
|
||||||
|
total: 0, // total number of messages
|
||||||
|
unread: 0, // total unread
|
||||||
|
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
|
||||||
|
refresh: false, // to listen from MessagesMixin
|
||||||
|
notificationsSupported: false,
|
||||||
|
notificationsEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mailbox.total,
|
||||||
|
(v) => {
|
||||||
|
if (v == 0) {
|
||||||
|
Tinycon.reset()
|
||||||
|
} else {
|
||||||
|
Tinycon.setBubble(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mailbox.showTagColors,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
localStorage.setItem('showTagsColors', '1')
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('showTagsColors')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
12
server/ui-src/stores/pagination.js
Normal file
12
server/ui-src/stores/pagination.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export const pagination = reactive({
|
||||||
|
start: 0, // pagination offset
|
||||||
|
limit: 50, // per page
|
||||||
|
total: 0, // total results of current view / filter
|
||||||
|
count: 0, // number of messages currently displayed
|
||||||
|
|
||||||
|
// increment() {
|
||||||
|
// this.count++
|
||||||
|
// }
|
||||||
|
})
|
@ -1,41 +0,0 @@
|
|||||||
|
|
||||||
<script>
|
|
||||||
import commonMixins from '../mixins.js';
|
|
||||||
|
|
||||||
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="'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="'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>
|
|
@ -1,38 +0,0 @@
|
|||||||
|
|
||||||
<script>
|
|
||||||
import commonMixins from '../mixins.js'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
headers: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
let self = this;
|
|
||||||
let uri = '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="vals, 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 vals" class="mb-2 text-break">{{ x }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
@ -1,461 +0,0 @@
|
|||||||
|
|
||||||
<script>
|
|
||||||
import commonMixins from '../mixins.js'
|
|
||||||
import Prism from "prismjs"
|
|
||||||
import Tags from "bootstrap5-tags"
|
|
||||||
import Attachments from './Attachments.vue'
|
|
||||||
import Headers from './Headers.vue'
|
|
||||||
import HTMLCheck from './MessageHTMLCheck.vue'
|
|
||||||
import LinkCheck from './MessageLinkCheck.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
existingTags: Array,
|
|
||||||
uiConfig: Object
|
|
||||||
},
|
|
||||||
|
|
||||||
components: {
|
|
||||||
Attachments,
|
|
||||||
Headers,
|
|
||||||
HTMLCheck,
|
|
||||||
LinkCheck,
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
srcURI: false,
|
|
||||||
iframes: [], // for resizing
|
|
||||||
showTags: false, // to force re-rendering of component
|
|
||||||
canSaveTags: false, // prevent auto-saving tags on render
|
|
||||||
messageTags: [],
|
|
||||||
allTags: [],
|
|
||||||
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.showTags = false
|
|
||||||
self.canSaveTags = false
|
|
||||||
self.allTags = self.existingTags
|
|
||||||
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 = 'api/v1/message/' + self.message.ID + '/raw'
|
|
||||||
self.resizeIFrames()
|
|
||||||
})
|
|
||||||
|
|
||||||
self.showTags = true
|
|
||||||
self.$nextTick(function () {
|
|
||||||
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('api/v1/tags', data, function (response) {
|
|
||||||
self.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 allTags" :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-lg-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="!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-lg-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="!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-lg-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="!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>
|
|
@ -1,670 +0,0 @@
|
|||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
|
||||||
import commonMixins from '../mixins.js'
|
|
||||||
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('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>
|
|
@ -1,398 +0,0 @@
|
|||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import commonMixins from '../mixins.js'
|
|
||||||
|
|
||||||
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
|
|
||||||
let self = this
|
|
||||||
this.loading = true
|
|
||||||
let uri = 'api/v1/message/' + self.message.ID + '/link-check'
|
|
||||||
if (this.followRedirects) {
|
|
||||||
uri += '?follow=true'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
@ -1,116 +0,0 @@
|
|||||||
|
|
||||||
<script>
|
|
||||||
import Tags from "bootstrap5-tags"
|
|
||||||
import commonMixins from '../mixins.js'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
uiConfig: Object,
|
|
||||||
releaseAddresses: Array
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
addresses: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [commonMixins],
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.addresses = JSON.parse(JSON.stringify(this.releaseAddresses))
|
|
||||||
this.$nextTick(function () {
|
|
||||||
Tags.init("select[multiple]")
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
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('api/v1/message/' + self.message.ID + '/release', data, function (response) {
|
|
||||||
self.modal("ReleaseModal").hide()
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<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 releaseAddresses" :value="t">{{ t }}</option>
|
|
||||||
</select>
|
|
||||||
<div class="invalid-feedback">Invalid email address</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-text text-center" v-if="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>{{ 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="uiConfig.MessageRelay.ReturnPath != ''">{{ 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 id="loading" v-if="loading">
|
|
||||||
<div class="d-flex justify-content-center align-items-center h-100">
|
|
||||||
<div class="spinner-border text-secondary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
@ -1,144 +0,0 @@
|
|||||||
|
|
||||||
<script>
|
|
||||||
import { domToPng } from 'modern-screenshot'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object,
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
html: false,
|
|
||||||
loading: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
initScreenshot: function () {
|
|
||||||
this.loading = true
|
|
||||||
let self = this
|
|
||||||
// remove base tag, if set
|
|
||||||
let h = this.message.HTML.replace(/<base .*>/mi, '')
|
|
||||||
|
|
||||||
// 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 = false
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div id="loading" v-if="loading">
|
|
||||||
<div class="d-flex justify-content-center align-items-center h-100">
|
|
||||||
<div class="spinner-border text-secondary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
@ -1,28 +0,0 @@
|
|||||||
<script>
|
|
||||||
import commonMixins from '../mixins.js'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [commonMixins]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
@ -1,44 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { Toast } from 'bootstrap'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
message: Object
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
let self = this
|
|
||||||
let el = document.getElementById('messageToast')
|
|
||||||
if (el) {
|
|
||||||
el.addEventListener('hidden.bs.toast', () => {
|
|
||||||
self.$emit("clearMessageToast")
|
|
||||||
})
|
|
||||||
|
|
||||||
let b = Toast.getOrCreateInstance(el)
|
|
||||||
b.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
|
||||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
|
||||||
<div class="toast-header">
|
|
||||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
|
||||||
<strong class="me-auto"><a :href="'#' + message.ID">New message</a></strong>
|
|
||||||
<small class="text-body-secondary">now</small>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toast-body">
|
|
||||||
<div>
|
|
||||||
<a :href="'#' + message.ID" class="d-block text-truncate text-body-secondary">
|
|
||||||
<template v-if="message.Subject != ''">{{ message.Subject }}</template>
|
|
||||||
<template v-else>[ no subject ]</template>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
@ -1,123 +0,0 @@
|
|||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
theme: 'auto',
|
|
||||||
icon: '#circle-half',
|
|
||||||
icons: {
|
|
||||||
'auto': '#circle-half',
|
|
||||||
'light': '#sun-fill',
|
|
||||||
'dark': '#moon-stars-fill'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.setTheme(this.getPreferredTheme())
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
getStoredTheme: function () {
|
|
||||||
let theme = localStorage.getItem('theme')
|
|
||||||
if (!theme) {
|
|
||||||
theme = 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
return theme
|
|
||||||
},
|
|
||||||
|
|
||||||
setStoredTheme: function (theme) {
|
|
||||||
localStorage.setItem('theme', theme)
|
|
||||||
this.setTheme(theme)
|
|
||||||
},
|
|
||||||
|
|
||||||
getPreferredTheme: function () {
|
|
||||||
const storedTheme = this.getStoredTheme()
|
|
||||||
if (storedTheme) {
|
|
||||||
return storedTheme
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
? 'dark'
|
|
||||||
: 'light'
|
|
||||||
},
|
|
||||||
|
|
||||||
setTheme: function (theme) {
|
|
||||||
this.icon = this.icons[theme]
|
|
||||||
this.theme = theme
|
|
||||||
if (
|
|
||||||
theme === 'auto' &&
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
) {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</template>
|
|
84
server/ui-src/views/MailboxView.vue
Normal file
84
server/ui-src/views/MailboxView.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from '../mixins/CommonMixins.js'
|
||||||
|
import MessagesMixins from '../mixins/MessagesMixins.js'
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins, MessagesMixins],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
AboutMailpit,
|
||||||
|
AjaxLoader,
|
||||||
|
ListMessages,
|
||||||
|
MailboxActions,
|
||||||
|
MailboxTags,
|
||||||
|
Pagination,
|
||||||
|
SearchForm,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mailbox,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
// 'mailbox.total'(v, old) {
|
||||||
|
// console.log(mailbox.total)
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.mailbox.searching = false
|
||||||
|
this.apiURI = this.$router.resolve(`/api/v1/messages`).href
|
||||||
|
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>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-9 col-lg-5">
|
||||||
|
<SearchForm />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0">
|
||||||
|
<Pagination @loadMessages="loadMessages" :total="mailbox.count" />
|
||||||
|
</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;">
|
||||||
|
<MailboxActions @loadMessages="loadMessages" />
|
||||||
|
<MailboxTags />
|
||||||
|
<AboutMailpit />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-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>
|
||||||
|
|
||||||
|
<AjaxLoader :loading="loading" />
|
||||||
|
</template>
|
7
server/ui-src/views/NotFoundView.vue
Normal file
7
server/ui-src/views/NotFoundView.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p>Page not found</p>
|
||||||
|
<p>Click <router-link to="/">here</router-link> to continue.</p>
|
||||||
|
</template>
|
100
server/ui-src/views/SearchView.vue
Normal file
100
server/ui-src/views/SearchView.vue
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<script>
|
||||||
|
import CommonMixins from '../mixins/CommonMixins.js'
|
||||||
|
import MessagesMixins from '../mixins/MessagesMixins.js'
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [CommonMixins, MessagesMixins],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
AboutMailpit,
|
||||||
|
AjaxLoader,
|
||||||
|
ListMessages,
|
||||||
|
SearchActions,
|
||||||
|
MailboxTags,
|
||||||
|
Pagination,
|
||||||
|
SearchForm,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mailbox,
|
||||||
|
pagination,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
$route(to, from) {
|
||||||
|
this.doSearch(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.mailbox.searching = true
|
||||||
|
this.doSearch(false)
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
doSearch: function (resetPagination) {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
let s = urlParams.get('q') ? urlParams.get('q') : '';
|
||||||
|
|
||||||
|
if (s == '') {
|
||||||
|
this.$router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetPagination) {
|
||||||
|
pagination.start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiURI = this.$router.resolve(`/api/v1/search`).href + '?query=' + encodeURIComponent(s)
|
||||||
|
this.loadMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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">
|
||||||
|
<img :src="baseURL + 'mailpit.svg'" alt=" Mailpit">
|
||||||
|
<span class="ms-2">Mailpit</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-9 col-lg-5">
|
||||||
|
<SearchForm />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0">
|
||||||
|
<Pagination @loadMessages="loadMessages" :total="mailbox.count" />
|
||||||
|
</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 />
|
||||||
|
<AboutMailpit />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-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>
|
||||||
|
|
||||||
|
<AjaxLoader :loading="loading" />
|
||||||
|
</template>
|
@ -1,22 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="h-100">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
||||||
<meta name="referrer" content="no-referrer">
|
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
|
||||||
<link rel="icon" href="favicon.svg">
|
|
||||||
<title>Mailpit</title>
|
|
||||||
<link rel=stylesheet href="dist/app.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="h-100">
|
|
||||||
<div class="container-fluid h-100 d-flex flex-column" id="app">
|
|
||||||
<noscript>You require JavaScript to use this app.</noscript>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="dist/app.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
@ -109,6 +109,12 @@ func searchParser(args []string) *sqlf.Stmt {
|
|||||||
} else {
|
} else {
|
||||||
q.Where("Read = 0")
|
q.Where("Read = 0")
|
||||||
}
|
}
|
||||||
|
} else if w == "is:tagged" {
|
||||||
|
if exclude {
|
||||||
|
q.Where("Tags = ?", "[]")
|
||||||
|
} else {
|
||||||
|
q.Where("Tags != ?", "[]")
|
||||||
|
}
|
||||||
} else if w == "has:attachment" || w == "has:attachments" {
|
} else if w == "has:attachment" || w == "has:attachments" {
|
||||||
if exclude {
|
if exclude {
|
||||||
q.Where("Attachments = 0")
|
q.Where("Attachments = 0")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user