1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-02-03 13:12:03 +02:00

Feature: New search filter [!]is:tagged

See #164
This commit is contained in:
Ralph Slooten 2023-09-14 22:30:20 +12:00
parent e18c45d0b3
commit ee49149df9
36 changed files with 1697 additions and 3615 deletions

22
package-lock.json generated
View File

@ -19,7 +19,8 @@
"rapidoc": "^9.3.4",
"tinycon": "^0.6.8",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0"
"vue-css-donut-chart": "^2.0.0",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
@ -829,6 +830,11 @@
"@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": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz",
@ -2413,6 +2419,20 @@
"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": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",

View File

@ -20,7 +20,8 @@
"rapidoc": "^9.3.4",
"tinycon": "^0.6.8",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0"
"vue-css-donut-chart": "^2.0.0",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",

View File

@ -12,6 +12,7 @@ import (
"os"
"strings"
"sync/atomic"
"text/template"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/apiv1"
@ -42,7 +43,7 @@ func Listen() {
go websockets.MessageHub.Run()
r := defaultRoutes()
r := apiRoutes()
// kubernetes probes
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
@ -51,18 +52,24 @@ func Listen() {
// proxy handler for screenshots
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// virtual filesystem for others
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// virtual filesystem for /dist/ & some individual files
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)))))
r.Path(config.Webroot + "favicon.ico").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
if config.Webroot != "/" {
redir := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
redirect := strings.TrimRight(config.Webroot, "/")
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)
if config.UIAuthFile != "" {
@ -81,7 +88,7 @@ func Listen() {
}
}
func defaultRoutes() *mux.Router {
func apiRoutes() *mux.Router {
r := mux.NewRouter()
// 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/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
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.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())
}

View File

@ -4,7 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"net/url"
@ -29,7 +29,7 @@ func Test_APIv1(t *testing.T) {
setup()
defer storage.Close()
r := defaultRoutes()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
@ -57,8 +57,8 @@ func Test_APIv1(t *testing.T) {
// read first 10
t.Log("Read first 10 messages including raw & headers")
putIDS := []string{}
for indx, msg := range m.Messages {
if indx == 10 {
for idx, msg := range m.Messages {
if idx == 10 {
break
}
@ -253,7 +253,7 @@ func clientGet(url string) ([]byte, error) {
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
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)
}
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
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)
}
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
return data, err
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,13 @@
import { createApp } from 'vue';
import App from './App.vue';
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import "./assets/styles.scss";
import "bootstrap-icons/font/bootstrap-icons.scss";
import "bootstrap";
createApp(App).mount('#app');
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@ -1,373 +1,369 @@
@import "./bootstrap";
[v-cloak] {
display: none !important;
display: none !important;
}
.navbar {
z-index: 99;
z-index: 99;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
img {
width: 40px;
}
img {
width: 40px;
}
@include media-breakpoint-down(md) {
padding: 0;
@include media-breakpoint-down(md) {
padding: 0;
img {
width: 35px;
}
}
}
img {
width: 35px;
}
}
}
}
.navbar-brand {
span {
opacity: 0.8;
transition: all 0.5s;
}
span {
opacity: 0.8;
transition: all 0.5s;
}
&:hover {
span {
opacity: 1;
}
}
&:hover {
span {
opacity: 1;
}
}
}
.nav-tabs .nav-link {
@include media-breakpoint-down(xl) {
padding-left: 10px;
padding-right: 10px;
}
@include media-breakpoint-down(xl) {
padding-left: 10px;
padding-right: 10px;
}
}
:not(.text-view) > a:not(.no-icon) {
&[href^="http://"],
&[href^="https://"]
{
&:after {
content: "\f1c5";
display: inline-block;
font-family: "bootstrap-icons" !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
margin-left: 4px;
}
}
&[href^="http://"],
&[href^="https://"]
{
&:after {
content: "\f1c5";
display: inline-block;
font-family: "bootstrap-icons" !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
margin-left: 4px;
}
}
}
#loading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
.loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
}
// dark mode adjustments
@include color-mode(dark) {
#loading {
background: rgba(0, 0, 0, 0.4);
}
.loader {
background: rgba(0, 0, 0, 0.4);
}
.token.tag,
.token.property {
color: #ee6969;
}
.token.tag,
.token.property {
color: #ee6969;
}
}
.message {
&.read {
color: $text-muted;
&.read {
color: $text-muted;
b {
font-weight: normal;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
b {
font-weight: normal;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family:
Courier New,
Courier,
System,
fixed-width;
font-size: 0.85em;
white-space: pre;
font-family: "Courier New", Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-html-source pre[class*="language-"] code {
white-space: pre-wrap;
white-space: pre-wrap;
}
#nav-plain-text .text-view {
white-space: pre-wrap;
white-space: pre-wrap;
}
.messageHeaders {
margin: 15px 0 0;
margin: 15px 0 0;
th {
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
th {
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
td {
vertical-align: top;
}
td {
vertical-align: top;
}
}
#nav-html {
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
}
#preview-html {
min-height: 300px;
min-height: 300px;
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
}
#responsive-view {
margin: auto;
transition: width 0.5s;
position: relative;
margin: auto;
transition: width 0.5s;
position: relative;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
iframe {
height: 100% !important;
background: #fff;
}
}
iframe {
height: 100% !important;
background: #fff;
}
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
}
.list-group-item.message:first-child {
border-top: 0;
border-top: 0;
}
body.blur {
.privacy {
filter: blur(3px);
}
.privacy {
filter: blur(3px);
}
}
.card.attachment {
color: $gray-800;
color: $gray-800;
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-footer {
background: $gray-300;
.card-footer {
background: $gray-300;
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
}
.form-select.tag-selector {
display: none;
display: none;
}
.form-control.dropdown {
padding: 0;
border: 0;
padding: 0;
border: 0;
input {
font-size: 0.875em;
}
input {
font-size: 0.875em;
}
div {
cursor: text; // html5-tags
}
div {
cursor: text; // html5-tags
}
}
// bootstrap5-tags
.tags-badge {
display: flex;
display: flex;
}
#DownloadBtn {
@include media-breakpoint-down(sm) {
position: static;
@include media-breakpoint-down(sm) {
position: static;
.dropdown-menu {
left: 0;
right: 0;
}
}
.dropdown-menu {
left: 0;
right: 0;
}
}
}
#ReleaseModal {
.form-control.dropdown {
div {
@extend .form-control;
}
}
.form-control.dropdown {
div {
@extend .form-control;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
pre[class*="language-"] {
// color: #000;
// background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
// color: #000;
// background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
position: relative;
overflow: visible;
position: relative;
overflow: visible;
}
pre[class*="language-"] > code {
position: relative;
z-index: 1;
position: relative;
z-index: 1;
}
code[class*="language-"] {
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
// background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
// background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
}
:not(pre) > code[class*="language-"] {
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
}
.token.block-comment,
@ -375,10 +371,10 @@ pre[class*="language-"] {
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
color: #5f6364;
}
.token.boolean,
.token.constant,
@ -388,7 +384,7 @@ pre[class*="language-"] {
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
@ -397,70 +393,70 @@ pre[class*="language-"] {
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
font-weight: 400;
}
.token.bold {
font-weight: 700;
font-weight: 700;
}
.token.italic {
font-style: italic;
font-style: italic;
}
// .token.entity {
// cursor: help;
// }
.token.namespace {
opacity: 0.7;
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
padding-left: 0;
}
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 {
left: 0;
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
margin-top: 0;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -14,17 +14,27 @@ FakeModal.prototype.show = function () { }
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
/* Common mixin functions used in apps */
const commonMixins = {
export default {
data() {
return {
loading: 0,
tagColorCache: {},
showTagColors: true
// showTagColors: true,
}
},
beforeMount() {
// this.baseURL = this.$router.resolve(`/`).href
},
mounted() {
this.showTagColors = localStorage.getItem('showTagsColors')
// this.showTagColors = localStorage.getItem('showTagsColors')
},
computed: {
baseURL() {
return window.baseURL
}
},
methods: {
@ -41,6 +51,14 @@ const commonMixins = {
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
handleError: function (error) {
// handle error
@ -224,17 +242,17 @@ const commonMixins = {
return this.tagColorCache[s]
},
toggleTagColors: function () {
if (this.showTagColors) {
localStorage.removeItem('showTagsColors')
this.showTagColors = false
} else {
localStorage.setItem('showTagsColors', '1')
this.showTagColors = true
}
}
// toggleTagColors: function () {
// if (this.showTagColors) {
// localStorage.removeItem('showTagsColors')
// this.showTagColors = false
// } else {
// localStorage.setItem('showTagsColors', '1')
// this.showTagColors = true
// }
// }
}
}
export default commonMixins;
// export default commonMixins;

View 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
})
},
}
}

View 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

View 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')
}
}
)

View 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++
// }
})

View File

@ -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>

View File

@ -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>

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/˱˱˱/g, '<')
.replace(/˲˲˲/g, '>')
.replace(/ˠˠˠ/g, '"')
return html
},
}
}
</script>
<template>
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100" 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">
&lt;{{ message.From.Address }}&gt;
</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 + " &lt;" + t.Address + "&gt;" }}</span>
</span>
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
</td>
</tr>
<tr v-if="message.Cc && message.Cc.length" class="small">
<th>Cc</th>
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
</td>
</tr>
<tr v-if="message.Bcc && message.Bcc.length" class="small">
<th>Bcc</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }}
</span>
</td>
</tr>
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
<th class="text-nowrap">Reply-To</th>
<td class="privacy text-body-secondary">
<span v-for="(t, i) in message.ReplyTo">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </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">&lt;{{ message.ReturnPath }}&gt;</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>

View File

@ -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> &nbsp;
<span class="text-nowrap">
<i class="bi bi-circle-fill text-warning"></i>
{{ round2dm(summary.Total.Partial) }}% partially supported
</span> &nbsp;
<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>&lt;script&gt;</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>&lt;script&gt;</code> node (element) has 100% failure (not supported in
any email client), and a <code>&lt;p&gt;</code> node has 100% pass (supported).
</p>
<ul>
<li>
An email containing just a single <code>&lt;script&gt;</code>: the final
score is 0% supported.
</li>
<li>
An email containing just a <code>&lt;script&gt;</code> and a
<code>&lt;p&gt;</code>: the final score is 50% supported.
</li>
<li>
An email containing just a <code>&lt;script&gt;</code> and two
<code>&lt;p&gt;</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>

View File

@ -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 &amp; 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 &amp; 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -109,6 +109,12 @@ func searchParser(args []string) *sqlf.Stmt {
} else {
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" {
if exclude {
q.Where("Attachments = 0")