mirror of
https://github.com/axllent/mailpit.git
synced 2025-07-03 00:46:58 +02:00
Feature(UI): List messages in side nav when viewing message for easy navigation (#336)
This commit is contained in:
@ -165,7 +165,7 @@ func Store(body *[]byte) (string, error) {
|
|||||||
|
|
||||||
// List returns a subset of messages from the mailbox,
|
// List returns a subset of messages from the mailbox,
|
||||||
// sorted latest to oldest
|
// sorted latest to oldest
|
||||||
func List(start, limit int) ([]MessageSummary, error) {
|
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||||
results := []MessageSummary{}
|
results := []MessageSummary{}
|
||||||
tsStart := time.Now()
|
tsStart := time.Now()
|
||||||
|
|
||||||
@ -175,6 +175,10 @@ func List(start, limit int) ([]MessageSummary, error) {
|
|||||||
Limit(limit).
|
Limit(limit).
|
||||||
Offset(start)
|
Offset(start)
|
||||||
|
|
||||||
|
if beforeTS > 0 {
|
||||||
|
q = q.Where("Created < ?", beforeTS)
|
||||||
|
}
|
||||||
|
|
||||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||||
var created float64
|
var created float64
|
||||||
var id string
|
var id string
|
||||||
@ -428,12 +432,12 @@ func LatestID(r *http.Request) (string, error) {
|
|||||||
|
|
||||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||||
if search != "" {
|
if search != "" {
|
||||||
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
|
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
messages, err = List(0, 1)
|
messages, err = List(0, 0, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -462,6 +466,13 @@ func MarkRead(id string) error {
|
|||||||
|
|
||||||
BroadcastMailboxStats()
|
BroadcastMailboxStats()
|
||||||
|
|
||||||
|
d := struct {
|
||||||
|
ID string
|
||||||
|
Read bool
|
||||||
|
}{ID: id, Read: true}
|
||||||
|
|
||||||
|
websockets.Broadcast("update", d)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -534,6 +545,13 @@ func MarkUnread(id string) error {
|
|||||||
|
|
||||||
BroadcastMailboxStats()
|
BroadcastMailboxStats()
|
||||||
|
|
||||||
|
d := struct {
|
||||||
|
ID string
|
||||||
|
Read bool
|
||||||
|
}{ID: id, Read: false}
|
||||||
|
|
||||||
|
websockets.Broadcast("update", d)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -621,6 +639,15 @@ func DeleteMessages(ids []string) error {
|
|||||||
|
|
||||||
BroadcastMailboxStats()
|
BroadcastMailboxStats()
|
||||||
|
|
||||||
|
// broadcast individual message deletions
|
||||||
|
for _, id := range toDelete {
|
||||||
|
d := struct {
|
||||||
|
ID string
|
||||||
|
}{ID: id}
|
||||||
|
|
||||||
|
websockets.Broadcast("delete", d)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -671,8 +698,9 @@ func DeleteAllMessages() error {
|
|||||||
|
|
||||||
logMessagesDeleted(total)
|
logMessagesDeleted(total)
|
||||||
|
|
||||||
websockets.Broadcast("prune", nil)
|
|
||||||
BroadcastMailboxStats()
|
BroadcastMailboxStats()
|
||||||
|
|
||||||
|
websockets.Broadcast("truncate", nil)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -122,7 +122,7 @@ func TestMessageSummary(t *testing.T) {
|
|||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
summaries, err := List(0, 1)
|
summaries, err := List(0, 0, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("error ", err)
|
t.Log("error ", err)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
|
@ -18,7 +18,7 @@ import (
|
|||||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||||
func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
|
func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {
|
||||||
results := []MessageSummary{}
|
results := []MessageSummary{}
|
||||||
allResults := []MessageSummary{}
|
allResults := []MessageSummary{}
|
||||||
tsStart := time.Now()
|
tsStart := time.Now()
|
||||||
@ -28,6 +28,11 @@ func Search(search, timezone string, start, limit int) ([]MessageSummary, int, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
q := searchQueryBuilder(search, timezone)
|
q := searchQueryBuilder(search, timezone)
|
||||||
|
|
||||||
|
if beforeTS > 0 {
|
||||||
|
q = q.Where(`Created < ?`, beforeTS)
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||||
|
@ -69,7 +69,7 @@ func TestSearch(t *testing.T) {
|
|||||||
|
|
||||||
search := uniqueSearches[searchIdx]
|
search := uniqueSearches[searchIdx]
|
||||||
|
|
||||||
summaries, _, err := Search(search, "", 0, 100)
|
summaries, _, err := Search(search, "", 0, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("error ", err)
|
t.Log("error ", err)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
@ -85,7 +85,7 @@ func TestSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// search something that will return 200 results
|
// search something that will return 200 results
|
||||||
summaries, _, err := Search("This is the email body", "", 0, testRuns)
|
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("error ", err)
|
t.Log("error ", err)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
@ -109,7 +109,7 @@ func TestSearchDelete100(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("error ", err)
|
t.Log("error ", err)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
@ -122,7 +122,7 @@ func TestSearchDelete100(t *testing.T) {
|
|||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("error ", err)
|
t.Log("error ", err)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
@ -143,7 +143,7 @@ func TestSearchDelete1100(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("error ", err)
|
t.Log("error ", err)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
@ -156,7 +156,7 @@ func TestSearchDelete1100(t *testing.T) {
|
|||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("error ", err)
|
t.Log("error ", err)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
|
@ -60,6 +60,13 @@ func SetMessageTags(id string, tags []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
d := struct {
|
||||||
|
ID string
|
||||||
|
Tags []string
|
||||||
|
}{ID: id, Tags: applyTags}
|
||||||
|
|
||||||
|
websockets.Broadcast("update", d)
|
||||||
|
|
||||||
return tagNames, nil
|
return tagNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.1.6",
|
||||||
"ical.js": "^2.0.1",
|
"ical.js": "^2.0.1",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"modern-screenshot": "^4.4.30",
|
"modern-screenshot": "^4.4.30",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
@ -1954,6 +1955,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||||
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.1.6",
|
||||||
"ical.js": "^2.0.1",
|
"ical.js": "^2.0.1",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"modern-screenshot": "^4.4.30",
|
"modern-screenshot": "^4.4.30",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
|
@ -10,12 +10,15 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/araddon/dateparse"
|
||||||
"github.com/axllent/mailpit/config"
|
"github.com/axllent/mailpit/config"
|
||||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||||
"github.com/axllent/mailpit/internal/linkcheck"
|
"github.com/axllent/mailpit/internal/linkcheck"
|
||||||
|
"github.com/axllent/mailpit/internal/logger"
|
||||||
"github.com/axllent/mailpit/internal/spamassassin"
|
"github.com/axllent/mailpit/internal/spamassassin"
|
||||||
"github.com/axllent/mailpit/internal/storage"
|
"github.com/axllent/mailpit/internal/storage"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetMessages returns a paginated list of messages as JSON
|
// GetMessages returns a paginated list of messages as JSON
|
||||||
@ -48,9 +51,9 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Responses:
|
// Responses:
|
||||||
// 200: MessagesSummaryResponse
|
// 200: MessagesSummaryResponse
|
||||||
// default: ErrorResponse
|
// default: ErrorResponse
|
||||||
start, limit := getStartLimit(r)
|
start, beforeTS, limit := getStartLimit(r)
|
||||||
|
|
||||||
messages, err := storage.List(start, limit)
|
messages, err := storage.List(start, beforeTS, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, err.Error())
|
httpError(w, err.Error())
|
||||||
return
|
return
|
||||||
@ -120,9 +123,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
start, limit := getStartLimit(r)
|
start, beforeTS, limit := getStartLimit(r)
|
||||||
|
|
||||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
|
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, err.Error())
|
httpError(w, err.Error())
|
||||||
return
|
return
|
||||||
@ -548,12 +551,20 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := storage.GetMessage(id)
|
raw, err := storage.GetMessageRaw(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fourOFour(w)
|
fourOFour(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e := bytes.NewReader(raw)
|
||||||
|
|
||||||
|
msg, err := enmime.ReadEnvelope(e)
|
||||||
|
if err != nil {
|
||||||
|
httpError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if msg.HTML == "" {
|
if msg.HTML == "" {
|
||||||
httpError(w, "message does not contain HTML")
|
httpError(w, "message does not contain HTML")
|
||||||
return
|
return
|
||||||
@ -704,9 +715,10 @@ func httpJSONError(w http.ResponseWriter, msg string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the start and limit based on query params. Defaults to 0, 50
|
// Get the start and limit based on query params. Defaults to 0, 50
|
||||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
|
||||||
start = 0
|
start = 0
|
||||||
limit = 50
|
limit = 50
|
||||||
|
beforeTS = 0 // timestamp
|
||||||
|
|
||||||
s := req.URL.Query().Get("start")
|
s := req.URL.Query().Get("start")
|
||||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||||
@ -718,7 +730,17 @@ func getStartLimit(req *http.Request) (start int, limit int) {
|
|||||||
limit = n
|
limit = n
|
||||||
}
|
}
|
||||||
|
|
||||||
return start, limit
|
b := req.URL.Query().Get("before")
|
||||||
|
if b != "" {
|
||||||
|
t, err := dateparse.ParseLocal(b)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log().Warnf("ignoring invalid before: date \"%s\"", b)
|
||||||
|
} else {
|
||||||
|
beforeTS = t.UnixMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return start, beforeTS, limit
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOptions returns a blank response
|
// GetOptions returns a blank response
|
||||||
|
@ -19,13 +19,13 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||||
if search != "" {
|
if search != "" {
|
||||||
messages, _, err = storage.Search(search, "", 0, 1)
|
messages, _, err = storage.Search(search, "", 0, 0, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, err.Error())
|
httpError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
messages, err = storage.List(0, 1)
|
messages, err = storage.List(0, 0, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, err.Error())
|
httpError(w, err.Error())
|
||||||
return
|
return
|
||||||
|
@ -26,9 +26,10 @@ func sendData(c net.Conn, m string) {
|
|||||||
fmt.Fprintf(c, "%s\r\n", m)
|
fmt.Fprintf(c, "%s\r\n", m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the latest 100 messages
|
||||||
func getMessages() ([]message, error) {
|
func getMessages() ([]message, error) {
|
||||||
messages := []message{}
|
messages := []message{}
|
||||||
list, err := storage.List(0, 100)
|
list, err := storage.List(0, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return messages, err
|
return messages, err
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,18 @@
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import mitt from 'mitt';
|
||||||
|
|
||||||
import './assets/styles.scss'
|
import './assets/styles.scss'
|
||||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||||
import 'bootstrap'
|
import 'bootstrap'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// Global event bus used to subscribe to websocket events
|
||||||
|
// such as message deletes, updates & truncation.
|
||||||
|
const eventBus = mitt()
|
||||||
|
app.provide('eventBus', eventBus)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
@ -91,44 +91,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-mailpit {
|
|
||||||
@include media-breakpoint-down(md) {
|
|
||||||
width: var(--bs-offcanvas-width);
|
|
||||||
margin-left: -1rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
.subject {
|
|
||||||
color: $text-muted;
|
|
||||||
|
|
||||||
b {
|
|
||||||
color: $list-group-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.read {
|
|
||||||
color: $text-muted;
|
|
||||||
|
|
||||||
b {
|
|
||||||
opacity: 0.7;
|
|
||||||
font-weight: normal;
|
|
||||||
color: $list-group-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.selected {
|
|
||||||
background: var(--bs-primary-bg-subtle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-spaces-nowrap {
|
.text-spaces-nowrap {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
@ -266,8 +228,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.message:first-child {
|
#message-page {
|
||||||
border-top: 0;
|
.list-group-item.message:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
.subject {
|
||||||
|
color: $text-muted;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: $list-group-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.read {
|
||||||
|
color: $text-muted;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: $list-group-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.selected {
|
||||||
|
background: var(--bs-primary-bg-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.blur {
|
body.blur {
|
||||||
@ -320,6 +309,18 @@ body.blur {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
&.read {
|
||||||
|
> div {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#message-view {
|
#message-view {
|
||||||
.form-control.dropdown {
|
.form-control.dropdown {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -54,14 +54,13 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="!modals">
|
<template v-if="!modals">
|
||||||
<div
|
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||||
class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
<button class="text-muted btn btn-sm ps-0" v-on:click="loadInfo()">
|
||||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
|
|
||||||
<i class="bi bi-info-circle-fill me-1"></i>
|
<i class="bi bi-info-circle-fill me-1"></i>
|
||||||
About
|
About
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
|
||||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
||||||
<i class="bi bi-gear-fill"></i>
|
<i class="bi bi-gear-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -152,7 +151,7 @@ export default {
|
|||||||
<div class="card-header h4">
|
<div class="card-header h4">
|
||||||
Runtime statistics
|
Runtime statistics
|
||||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||||
v-on:click="loadInfo">
|
v-on:click="loadInfo()">
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -183,8 +182,8 @@ export default {
|
|||||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||||
<small class="text-secondary">
|
<small class="text-secondary">
|
||||||
({{
|
({{
|
||||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||||
}})
|
}})
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -142,7 +142,7 @@ export default {
|
|||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||||
{{ getPrimaryEmailTo(message) }}
|
To: {{ getPrimaryEmailTo(message) }}
|
||||||
<span v-if="message.To && message.To.length > 1">
|
<span v-if="message.To && message.To.length > 1">
|
||||||
[+{{ message.To.length - 1 }}]
|
[+{{ message.To.length - 1 }}]
|
||||||
</span>
|
</span>
|
||||||
|
@ -100,7 +100,7 @@ export default {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group mt-1 mb-5 pb-3">
|
<div class="list-group mt-1 mb-2">
|
||||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click="hideNav"
|
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click="hideNav"
|
||||||
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
|
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
|
||||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||||
|
@ -7,6 +7,9 @@ import { pagination } from '../stores/pagination'
|
|||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pagination,
|
pagination,
|
||||||
@ -63,7 +66,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// update pagination offset
|
// update pagination offset
|
||||||
pagination.start++
|
pagination.start++
|
||||||
// prevent "Too many calls to Location or History APIs within a short timeframe"
|
// prevent "Too many calls to Location or History APIs within a short time frame"
|
||||||
this.delayedPaginationUpdate()
|
this.delayedPaginationUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -91,6 +94,7 @@ export default {
|
|||||||
window.scrollInPlace = true
|
window.scrollInPlace = true
|
||||||
mailbox.refresh = true // trigger refresh
|
mailbox.refresh = true // trigger refresh
|
||||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||||
|
this.eventBus.emit("prune");
|
||||||
} else if (response.Type == "stats" && response.Data) {
|
} else if (response.Type == "stats" && response.Data) {
|
||||||
// refresh mailbox stats
|
// refresh mailbox stats
|
||||||
mailbox.total = response.Data.Total
|
mailbox.total = response.Data.Total
|
||||||
@ -100,6 +104,15 @@ export default {
|
|||||||
if (this.version != response.Data.Version) {
|
if (this.version != response.Data.Version) {
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
|
} else if (response.Type == "delete" && response.Data) {
|
||||||
|
// broadcast for components
|
||||||
|
this.eventBus.emit("delete", response.Data);
|
||||||
|
} else if (response.Type == "update" && response.Data) {
|
||||||
|
// broadcast for components
|
||||||
|
this.eventBus.emit("update", response.Data);
|
||||||
|
} else if (response.Type == "truncate") {
|
||||||
|
// broadcast for components
|
||||||
|
this.eventBus.emit("truncate")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,6 +164,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleMessageEvents(message) {
|
||||||
|
console.log("generic")
|
||||||
|
console.log(message)
|
||||||
|
},
|
||||||
|
|
||||||
socketBreakReset() {
|
socketBreakReset() {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.socketBreaks = 0
|
this.socketBreaks = 0
|
||||||
|
@ -403,11 +403,13 @@ export default {
|
|||||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="d-md-none small">
|
<tr class="small">
|
||||||
<th class="small">Date</th>
|
<th class="small">Date</th>
|
||||||
<td>{{ messageDate(message.Date) }}</td>
|
<td>
|
||||||
|
{{ messageDate(message.Date) }}
|
||||||
|
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr class="small">
|
<tr class="small">
|
||||||
<th>Tags</th>
|
<th>Tags</th>
|
||||||
<td>
|
<td>
|
||||||
|
@ -115,8 +115,10 @@ export default {
|
|||||||
* @params function callback function
|
* @params function callback function
|
||||||
* @params function error callback function
|
* @params function error callback function
|
||||||
*/
|
*/
|
||||||
get(url, values, callback, errorCallback) {
|
get(url, values, callback, errorCallback, hideLoader) {
|
||||||
this.loading++
|
if (!hideLoader) {
|
||||||
|
this.loading++
|
||||||
|
}
|
||||||
axios.get(url, { params: values })
|
axios.get(url, { params: values })
|
||||||
.then(callback)
|
.then(callback)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@ -128,7 +130,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// always executed
|
// always executed
|
||||||
if (this.loading > 0) {
|
if (!hideLoader && this.loading > 0) {
|
||||||
this.loading--
|
this.loading--
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -273,6 +275,11 @@ export default {
|
|||||||
return 'bi-file-arrow-down-fill'
|
return 'bi-file-arrow-down-fill'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// wrapper to update one or more messages with
|
||||||
|
updateMessages(messages, updates) {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
// Returns a hex color based on a string.
|
// Returns a hex color based on a string.
|
||||||
// Values are stored in an array for faster lookup / processing.
|
// Values are stored in an array for faster lookup / processing.
|
||||||
colorHash(s) {
|
colorHash(s) {
|
||||||
|
@ -14,6 +14,9 @@ import { pagination } from "../stores/pagination";
|
|||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins, MessagesMixins],
|
mixins: [CommonMixins, MessagesMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AboutMailpit,
|
AboutMailpit,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
@ -27,6 +30,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
|
delayedRefresh: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -40,6 +44,18 @@ export default {
|
|||||||
mailbox.searching = false
|
mailbox.searching = false
|
||||||
this.apiURI = this.resolve(`/api/v1/messages`)
|
this.apiURI = this.resolve(`/api/v1/messages`)
|
||||||
this.loadMailbox()
|
this.loadMailbox()
|
||||||
|
|
||||||
|
// subscribe to events
|
||||||
|
this.eventBus.on("update", this.handleWSUpdate)
|
||||||
|
this.eventBus.on("delete", this.handleWSDelete)
|
||||||
|
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
// unsubscribe from events
|
||||||
|
this.eventBus.off("update", this.handleWSUpdate)
|
||||||
|
this.eventBus.off("delete", this.handleWSDelete)
|
||||||
|
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
@ -55,7 +71,51 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.loadMessages()
|
this.loadMessages()
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// handler for websocket message updates
|
||||||
|
handleWSUpdate(data) {
|
||||||
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
|
if (this.mailbox.messages[x].ID == data.ID) {
|
||||||
|
// update message
|
||||||
|
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// handler for websocket message deletion
|
||||||
|
handleWSDelete(data) {
|
||||||
|
let removed = 0;
|
||||||
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
|
if (this.mailbox.messages[x].ID == data.ID) {
|
||||||
|
// remove message from the list
|
||||||
|
this.mailbox.messages.splice(x, 1)
|
||||||
|
removed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removed || this.delayedRefresh) {
|
||||||
|
// nothing changed on this screen, or a refresh is queued,
|
||||||
|
// don't refresh
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||||
|
this.delayedRefresh = true
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.delayedRefresh = false
|
||||||
|
this.loadMessages()
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
|
||||||
|
// handler for websocket message truncation
|
||||||
|
handleWSTruncate() {
|
||||||
|
// all messages gone, reload
|
||||||
|
this.loadMessages()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -89,18 +149,24 @@ export default {
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||||
aria-label="Close"></button>
|
aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body pb-0">
|
||||||
<NavMailbox @loadMessages="loadMessages" />
|
<div class="d-flex flex-column h-100">
|
||||||
<NavTags />
|
<div class="flex-grow-1 overflow-y-auto">
|
||||||
<AboutMailpit />
|
|
||||||
|
<NavMailbox @loadMessages="loadMessages" />
|
||||||
|
<NavTags />
|
||||||
|
</div>
|
||||||
|
<AboutMailpit />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row flex-fill" style="min-height:0">
|
<div class="row flex-fill" style="min-height:0">
|
||||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||||
style="overflow-y: auto; overflow-x: hidden;">
|
<div class="flex-grow-1 overflow-y-auto">
|
||||||
<NavMailbox @loadMessages="loadMessages" />
|
<NavMailbox @loadMessages="loadMessages" />
|
||||||
<NavTags />
|
<NavTags />
|
||||||
|
</div>
|
||||||
<AboutMailpit />
|
<AboutMailpit />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -7,10 +7,14 @@ import Release from '../components/message/Release.vue'
|
|||||||
import Screenshot from '../components/message/Screenshot.vue'
|
import Screenshot from '../components/message/Screenshot.vue'
|
||||||
import { mailbox } from '../stores/mailbox'
|
import { mailbox } from '../stores/mailbox'
|
||||||
import { pagination } from '../stores/pagination'
|
import { pagination } from '../stores/pagination'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins],
|
mixins: [CommonMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AboutMailpit,
|
AboutMailpit,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
@ -24,20 +28,105 @@ export default {
|
|||||||
mailbox,
|
mailbox,
|
||||||
pagination,
|
pagination,
|
||||||
message: false,
|
message: false,
|
||||||
prevLink: false,
|
|
||||||
nextLink: false,
|
|
||||||
errorMessage: false,
|
errorMessage: false,
|
||||||
|
apiSideNavURI: false,
|
||||||
|
apiSideNavParams: URLSearchParams,
|
||||||
|
apiIsMore: true,
|
||||||
|
messagesList: [],
|
||||||
|
scrollLoading: false,
|
||||||
|
canLoadMore: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
$route(to, from) {
|
$route(to, from) {
|
||||||
this.loadMessage()
|
this.loadMessage()
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||||
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
|
this.initLoadMoreAPIParams()
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadMessage()
|
this.loadMessage()
|
||||||
|
|
||||||
|
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
|
||||||
|
if (!this.messagesList.length) {
|
||||||
|
this.loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshUI()
|
||||||
|
|
||||||
|
// subscribe to events
|
||||||
|
this.eventBus.on("update", this.handleWSUpdate)
|
||||||
|
this.eventBus.on("delete", this.handleWSDelete)
|
||||||
|
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
// unsubscribe from events
|
||||||
|
this.eventBus.off("update", this.handleWSUpdate)
|
||||||
|
this.eventBus.off("delete", this.handleWSDelete)
|
||||||
|
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
// get current message read status
|
||||||
|
isRead() {
|
||||||
|
const l = this.messagesList.length
|
||||||
|
if (!this.message || !l) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = false
|
||||||
|
for (x = 0; x < l; x++) {
|
||||||
|
if (this.messagesList[x].ID == this.message.ID) {
|
||||||
|
return this.messagesList[x].Read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
// get the previous message ID
|
||||||
|
previousID() {
|
||||||
|
const l = this.messagesList.length
|
||||||
|
if (!this.message || !l) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = false
|
||||||
|
for (x = 0; x < l; x++) {
|
||||||
|
if (this.messagesList[x].ID == this.message.ID) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
id = this.messagesList[x].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
// get the next message ID
|
||||||
|
nextID() {
|
||||||
|
const l = this.messagesList.length
|
||||||
|
if (!this.message || !l) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = false
|
||||||
|
for (x = l - 1; x > 0; x--) {
|
||||||
|
if (this.messagesList[x].ID == this.message.ID) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
id = this.messagesList[x].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
@ -48,9 +137,8 @@ export default {
|
|||||||
this.errorMessage = false
|
this.errorMessage = false
|
||||||
const d = response.data
|
const d = response.data
|
||||||
|
|
||||||
if (this.wasUnread(d.ID)) {
|
// update read status in case websockets is not working
|
||||||
mailbox.unread--
|
this.handleWSUpdate({ 'ID': d.ID, Read: true })
|
||||||
}
|
|
||||||
|
|
||||||
// replace inline images embedded as inline attachments
|
// replace inline images embedded as inline attachments
|
||||||
if (d.HTML && d.Inline) {
|
if (d.HTML && d.Inline) {
|
||||||
@ -94,7 +182,9 @@ export default {
|
|||||||
|
|
||||||
this.message = d
|
this.message = d
|
||||||
|
|
||||||
this.detectPrevNext()
|
this.$nextTick(() => {
|
||||||
|
this.scrollSidebarToCurrent()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.errorMessage = true
|
this.errorMessage = true
|
||||||
@ -114,37 +204,145 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// try detect whether this message was unread based on messages listing
|
// UI refresh ticker to adjust relative times
|
||||||
wasUnread(id) {
|
refreshUI() {
|
||||||
for (let m in mailbox.messages) {
|
window.setTimeout(() => {
|
||||||
if (mailbox.messages[m].ID == id) {
|
this.$forceUpdate()
|
||||||
if (!mailbox.messages[m].Read) {
|
this.refreshUI()
|
||||||
mailbox.messages[m].Read = true
|
}, 30000)
|
||||||
return true
|
},
|
||||||
}
|
|
||||||
return false
|
// handler for websocket message updates
|
||||||
|
handleWSUpdate(data) {
|
||||||
|
for (let x = 0; x < this.messagesList.length; x++) {
|
||||||
|
if (this.messagesList[x].ID == data.ID) {
|
||||||
|
// update message
|
||||||
|
this.messagesList[x] = { ...this.messagesList[x], ...data }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
detectPrevNext() {
|
// handler for websocket message deletion
|
||||||
// generate the prev/next links based on current message list
|
handleWSDelete(data) {
|
||||||
this.prevLink = false
|
for (let x = 0; x < this.messagesList.length; x++) {
|
||||||
this.nextLink = false
|
if (this.messagesList[x].ID == data.ID) {
|
||||||
let found = false
|
// remove message from the list
|
||||||
|
this.messagesList.splice(x, 1)
|
||||||
for (let m in mailbox.messages) {
|
return
|
||||||
if (mailbox.messages[m].ID == this.message.ID) {
|
|
||||||
found = true
|
|
||||||
} else if (found && !this.nextLink) {
|
|
||||||
this.nextLink = mailbox.messages[m].ID
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
this.prevLink = mailbox.messages[m].ID
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// handler for websocket message truncation
|
||||||
|
handleWSTruncate() {
|
||||||
|
// all messages gone, go to inbox
|
||||||
|
this.$router.push('/')
|
||||||
|
},
|
||||||
|
|
||||||
|
// return whether the sidebar is visible
|
||||||
|
sidebarVisible() {
|
||||||
|
return this.$refs.MessageList.offsetParent != null
|
||||||
|
},
|
||||||
|
|
||||||
|
// scroll sidenav to current message if found
|
||||||
|
scrollSidebarToCurrent() {
|
||||||
|
const cont = document.getElementById('MessageList')
|
||||||
|
if (!cont) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const c = cont.querySelector('.router-link-active')
|
||||||
|
if (c) {
|
||||||
|
const outer = cont.getBoundingClientRect()
|
||||||
|
const li = c.getBoundingClientRect()
|
||||||
|
if (outer.top > li.top || outer.bottom < li.bottom) {
|
||||||
|
c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollHandler(e) {
|
||||||
|
if (!this.canLoadMore || this.scrollLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollTop, offsetHeight, scrollHeight } = e.target
|
||||||
|
if ((scrollTop + offsetHeight + 150) >= scrollHeight) {
|
||||||
|
this.loadMore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMore() {
|
||||||
|
if (this.messagesList.length) {
|
||||||
|
// get last created timestamp
|
||||||
|
const oldest = this.messagesList[this.messagesList.length - 1].Created
|
||||||
|
// if set append `before=<ts>`
|
||||||
|
this.apiSideNavParams.set('before', oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollLoading = true
|
||||||
|
|
||||||
|
this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => {
|
||||||
|
if (response.data.messages.length) {
|
||||||
|
this.messagesList.push(...response.data.messages)
|
||||||
|
} else {
|
||||||
|
this.canLoadMore = false
|
||||||
|
}
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollLoading = false
|
||||||
|
})
|
||||||
|
}, null, true)
|
||||||
|
},
|
||||||
|
|
||||||
|
initLoadMoreAPIParams() {
|
||||||
|
let apiURI = this.resolve(`/api/v1/messages`)
|
||||||
|
let p = {}
|
||||||
|
|
||||||
|
if (mailbox.searching) {
|
||||||
|
apiURI = this.resolve(`/api/v1/search`)
|
||||||
|
p.query = mailbox.searching
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pagination.limit != pagination.defaultLimit) {
|
||||||
|
p.limit = pagination.limit.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiSideNavURI = apiURI
|
||||||
|
|
||||||
|
this.apiSideNavParams = new URLSearchParams(p)
|
||||||
|
},
|
||||||
|
|
||||||
|
getRelativeCreated(message) {
|
||||||
|
const d = new Date(message.Created)
|
||||||
|
return dayjs(d).fromNow()
|
||||||
|
},
|
||||||
|
|
||||||
|
getPrimaryEmailTo(message) {
|
||||||
|
for (let i in message.To) {
|
||||||
|
return message.To[i].Address
|
||||||
|
}
|
||||||
|
|
||||||
|
return '[ Undisclosed recipients ]'
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive(id) {
|
||||||
|
return this.message.ID == id
|
||||||
|
},
|
||||||
|
|
||||||
|
toTagUrl(t) {
|
||||||
|
if (t.match(/ /)) {
|
||||||
|
t = `"${t}"`
|
||||||
|
}
|
||||||
|
const p = {
|
||||||
|
q: 'tag:' + t
|
||||||
|
}
|
||||||
|
if (pagination.limit != pagination.defaultLimit) {
|
||||||
|
p.limit = pagination.limit.toString()
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(p)
|
||||||
|
return '/search?' + params.toString()
|
||||||
|
},
|
||||||
|
|
||||||
downloadMessageBody(str, ext) {
|
downloadMessageBody(str, ext) {
|
||||||
const dl = document.createElement('a')
|
const dl = document.createElement('a')
|
||||||
dl.href = "data:text/plain," + encodeURIComponent(str)
|
dl.href = "data:text/plain," + encodeURIComponent(str)
|
||||||
@ -157,25 +355,44 @@ export default {
|
|||||||
this.$refs.ScreenshotRef.initScreenshot()
|
this.$refs.ScreenshotRef.initScreenshot()
|
||||||
},
|
},
|
||||||
|
|
||||||
// mark current message as read
|
// toggle current message read status
|
||||||
markUnread() {
|
toggleRead() {
|
||||||
if (!this.message) {
|
if (!this.message) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const read = !this.isRead
|
||||||
|
|
||||||
|
const ids = [this.message.ID]
|
||||||
const uri = this.resolve('/api/v1/messages')
|
const uri = this.resolve('/api/v1/messages')
|
||||||
this.put(uri, { 'read': false, 'ids': [this.message.ID] }, (response) => {
|
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
|
||||||
this.goBack()
|
if (!this.sidebarVisible()) {
|
||||||
|
return this.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// manually update read status in case websockets is not working
|
||||||
|
this.handleWSUpdate({ 'ID': this.message.ID, Read: read })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteMessage() {
|
deleteMessage() {
|
||||||
const ids = [this.message.ID]
|
const ids = [this.message.ID]
|
||||||
const uri = this.resolve('/api/v1/messages')
|
const uri = this.resolve('/api/v1/messages')
|
||||||
this.delete(uri, { 'ids': ids }, () => {
|
// calculate next ID before deletion to prevent WS race
|
||||||
this.goBack()
|
const goToID = this.nextID ? this.nextID : this.previousID
|
||||||
|
|
||||||
|
this.delete(uri, { 'IDs': ids }, () => {
|
||||||
|
if (!this.sidebarVisible()) {
|
||||||
|
return this.goBack()
|
||||||
|
}
|
||||||
|
if (goToID) {
|
||||||
|
return this.$router.push('/view/' + goToID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.goBack()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// return to mailbox or search based on origin
|
||||||
goBack() {
|
goBack() {
|
||||||
mailbox.lastMessage = this.$route.params.id
|
mailbox.lastMessage = this.$route.params.id
|
||||||
|
|
||||||
@ -189,8 +406,7 @@ export default {
|
|||||||
if (pagination.limit != pagination.defaultLimit) {
|
if (pagination.limit != pagination.defaultLimit) {
|
||||||
p.limit = pagination.limit.toString()
|
p.limit = pagination.limit.toString()
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams(p)
|
this.$router.push('/search?' + new URLSearchParams(p).toString())
|
||||||
this.$router.push('/search?' + params.toString())
|
|
||||||
} else {
|
} else {
|
||||||
const p = {}
|
const p = {}
|
||||||
if (pagination.start > 0) {
|
if (pagination.start > 0) {
|
||||||
@ -199,8 +415,7 @@ export default {
|
|||||||
if (pagination.limit != pagination.defaultLimit) {
|
if (pagination.limit != pagination.defaultLimit) {
|
||||||
p.limit = pagination.limit.toString()
|
p.limit = pagination.limit.toString()
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams(p)
|
this.$router.push('/?' + new URLSearchParams(p).toString())
|
||||||
this.$router.push('/?' + params.toString())
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -218,25 +433,26 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||||
<div class="d-none d-md-block col-xl-2 col-md-3 col-auto pe-0">
|
<div class="d-none d-md-block col-xl-3 col-auto pe-0">
|
||||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||||
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4k col-lg-5 col-xl-6" v-if="!errorMessage">
|
<div class="col col-xl-5" v-if="!errorMessage">
|
||||||
<button @click="goBack()" class="btn btn-outline-light me-3 me-sm-4 d-md-none" title="Return to messages">
|
<button @click="goBack()" class="btn btn-outline-light me-3 me-sm-4 d-md-none" title="Return to messages">
|
||||||
<i class="bi bi-arrow-return-left"></i>
|
<i class="bi bi-arrow-return-left"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="markUnread">
|
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()">
|
||||||
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
|
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
|
||||||
|
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
|
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
|
||||||
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
|
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
|
||||||
v-on:click="initReleaseModal">
|
v-on:click="initReleaseModal()">
|
||||||
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
|
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage">
|
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()">
|
||||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -297,19 +513,18 @@ export default {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||||
:class="prevLink ? '' : 'disabled'" title="View previous message">
|
:class="previousID ? '' : 'disabled'" title="View previous message">
|
||||||
<i class="bi bi-caret-left-fill"></i>
|
<i class="bi bi-caret-left-fill"></i>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="'/view/' + nextLink" class="btn btn-outline-light" :class="nextLink ? '' : 'disabled'">
|
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
|
||||||
<i class="bi bi-caret-right-fill" title="View next message"></i>
|
<i class="bi bi-caret-right-fill" title="View next message"></i>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row flex-fill" style="min-height:0">
|
<div class="row flex-fill" style="min-height:0">
|
||||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
|
||||||
style="overflow-y: auto; overflow-x: hidden;">
|
|
||||||
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
|
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
|
||||||
v-if="mailbox.uiConfig.Label">
|
v-if="mailbox.uiConfig.Label">
|
||||||
{{ mailbox.uiConfig.Label }}
|
{{ mailbox.uiConfig.Label }}
|
||||||
@ -318,7 +533,11 @@ export default {
|
|||||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||||
<button @click="goBack()" class="list-group-item list-group-item-action">
|
<button @click="goBack()" class="list-group-item list-group-item-action">
|
||||||
<i class="bi bi-arrow-return-left me-1"></i>
|
<i class="bi bi-arrow-return-left me-1"></i>
|
||||||
<span class="ms-1">Return</span>
|
<span class="ms-1">
|
||||||
|
Return to
|
||||||
|
<template v-if="mailbox.searching">search</template>
|
||||||
|
<template v-else>mailbox</template>
|
||||||
|
</span>
|
||||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||||
v-if="mailbox.unread && !errorMessage">
|
v-if="mailbox.unread && !errorMessage">
|
||||||
{{ formatNumber(mailbox.unread) }}
|
{{ formatNumber(mailbox.unread) }}
|
||||||
@ -326,24 +545,45 @@ export default {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4" v-if="!errorMessage">
|
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
|
||||||
<div class="card-body text-body-secondary small">
|
@scroll="scrollHandler">
|
||||||
<p class="card-text">
|
<template v-if="messagesList && messagesList.length">
|
||||||
<b>Message date:</b><br>
|
<div class="list-group">
|
||||||
<small>{{ messageDate(message.Date) }}</small>
|
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
|
||||||
</p>
|
:id="message.ID"
|
||||||
<p class="card-text">
|
class="row gx-1 message d-flex small list-group-item list-group-item-action"
|
||||||
<b>Size:</b> {{ getFileSize(message.Size) }}
|
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
|
||||||
</p>
|
<div class="col-12 overflow-x-hidden">
|
||||||
<p class="card-text" v-if="allAttachments(message).length">
|
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||||
<b>Attachments:</b> {{ allAttachments(message).length }}
|
</div>
|
||||||
</p>
|
<div class="col overflow-x-hidden">
|
||||||
</div>
|
<div class="text-truncate privacy small">
|
||||||
|
To: {{ getPrimaryEmailTo(message) }}
|
||||||
|
<span v-if="message.To && message.To.length > 1">
|
||||||
|
[+{{ message.To.length - 1 }}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto small">
|
||||||
|
{{ getRelativeCreated(message) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="message.Tags.length" class="col-12">
|
||||||
|
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
||||||
|
v-on:click="pagination.start = 0"
|
||||||
|
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||||
|
:title="'Filter messages tagged with ' + t">
|
||||||
|
{{ t }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AboutMailpit />
|
<AboutMailpit />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
|
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||||
<div class="mh-100" style="overflow-y: auto;" id="message-page">
|
<div class="mh-100" style="overflow-y: auto;" id="message-page">
|
||||||
<template v-if="errorMessage">
|
<template v-if="errorMessage">
|
||||||
<h3 class="text-center my-3">
|
<h3 class="text-center my-3">
|
||||||
|
@ -14,6 +14,9 @@ import { pagination } from '../stores/pagination'
|
|||||||
export default {
|
export default {
|
||||||
mixins: [CommonMixins, MessagesMixins],
|
mixins: [CommonMixins, MessagesMixins],
|
||||||
|
|
||||||
|
// global event bus to handle message status changes
|
||||||
|
inject: ["eventBus"],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AboutMailpit,
|
AboutMailpit,
|
||||||
AjaxLoader,
|
AjaxLoader,
|
||||||
@ -28,6 +31,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
mailbox,
|
mailbox,
|
||||||
pagination,
|
pagination,
|
||||||
|
delayedRefresh: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -40,6 +44,18 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
mailbox.searching = this.getSearch()
|
mailbox.searching = this.getSearch()
|
||||||
this.doSearch()
|
this.doSearch()
|
||||||
|
|
||||||
|
// subscribe to events
|
||||||
|
this.eventBus.on("update", this.handleWSUpdate)
|
||||||
|
this.eventBus.on("delete", this.handleWSDelete)
|
||||||
|
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
// unsubscribe from events
|
||||||
|
this.eventBus.off("update", this.handleWSUpdate)
|
||||||
|
this.eventBus.off("delete", this.handleWSDelete)
|
||||||
|
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
@ -59,7 +75,50 @@ export default {
|
|||||||
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||||
}
|
}
|
||||||
this.loadMessages()
|
this.loadMessages()
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// handler for websocket message updates
|
||||||
|
handleWSUpdate(data) {
|
||||||
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
|
if (this.mailbox.messages[x].ID == data.ID) {
|
||||||
|
// update message
|
||||||
|
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// handler for websocket message deletion
|
||||||
|
handleWSDelete(data) {
|
||||||
|
let removed = 0;
|
||||||
|
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||||
|
if (this.mailbox.messages[x].ID == data.ID) {
|
||||||
|
// remove message from the list
|
||||||
|
this.mailbox.messages.splice(x, 1)
|
||||||
|
removed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removed || this.delayedRefresh) {
|
||||||
|
// nothing changed on this screen, or a refresh is queued, don't refresh
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||||
|
this.delayedRefresh = true
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.delayedRefresh = false
|
||||||
|
this.loadMessages()
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
|
||||||
|
// handler for websocket message truncation
|
||||||
|
handleWSTruncate() {
|
||||||
|
// all messages deleted, go back to inbox
|
||||||
|
this.$router.push('/')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -93,18 +152,23 @@ export default {
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||||
aria-label="Close"></button>
|
aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body pb-0">
|
||||||
<NavSearch @loadMessages="loadMessages" />
|
<div class="d-flex flex-column h-100">
|
||||||
<NavTags @loadMessages="loadMessages" />
|
<div class="flex-grow-1 overflow-y-auto">
|
||||||
<AboutMailpit />
|
<NavSearch @loadMessages="loadMessages" />
|
||||||
|
<NavTags />
|
||||||
|
</div>
|
||||||
|
<AboutMailpit />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row flex-fill" style="min-height:0">
|
<div class="row flex-fill" style="min-height:0">
|
||||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||||
style="overflow-y: auto; overflow-x: hidden;">
|
<div class="flex-grow-1 overflow-y-auto">
|
||||||
<NavSearch @loadMessages="loadMessages" />
|
<NavSearch @loadMessages="loadMessages" />
|
||||||
<NavTags @loadMessages="loadMessages" />
|
<NavTags />
|
||||||
|
</div>
|
||||||
<AboutMailpit />
|
<AboutMailpit />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user