mirror of
https://github.com/axllent/mailpit.git
synced 2025-05-15 22:16:44 +02:00
Feature(UI): List messages in side nav when viewing message for easy navigation (#336)
This commit is contained in:
parent
54e0c32948
commit
a1cb0af639
@ -165,7 +165,7 @@ func Store(body *[]byte) (string, error) {
|
||||
|
||||
// List returns a subset of messages from the mailbox,
|
||||
// sorted latest to oldest
|
||||
func List(start, limit int) ([]MessageSummary, error) {
|
||||
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
|
||||
@ -175,6 +175,10 @@ func List(start, limit int) ([]MessageSummary, error) {
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where("Created < ?", beforeTS)
|
||||
}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var id string
|
||||
@ -428,12 +432,12 @@ func LatestID(r *http.Request) (string, error) {
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
messages, err = List(0, 1)
|
||||
messages, err = List(0, 0, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -462,6 +466,13 @@ func MarkRead(id string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@ -534,6 +545,13 @@ func MarkUnread(id string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@ -621,6 +639,15 @@ func DeleteMessages(ids []string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
// broadcast individual message deletions
|
||||
for _, id := range toDelete {
|
||||
d := struct {
|
||||
ID string
|
||||
}{ID: id}
|
||||
|
||||
websockets.Broadcast("delete", d)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -671,8 +698,9 @@ func DeleteAllMessages() error {
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
websockets.Broadcast("truncate", nil)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@ -122,7 +122,7 @@ func TestMessageSummary(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
summaries, err := List(0, 1)
|
||||
summaries, err := List(0, 0, 1)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
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:
|
||||
// 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 `!`
|
||||
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{}
|
||||
allResults := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
@ -28,6 +28,11 @@ func Search(search, timezone string, start, limit int) ([]MessageSummary, int, e
|
||||
}
|
||||
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
|
@ -69,7 +69,7 @@ func TestSearch(t *testing.T) {
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, "", 0, 100)
|
||||
summaries, _, err := Search(search, "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@ -85,7 +85,7 @@ func TestSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Log("error ", err)
|
||||
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 {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@ -122,7 +122,7 @@ func TestSearchDelete100(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
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 {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@ -156,7 +156,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
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
|
||||
}
|
||||
|
||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.1.6",
|
||||
"ical.js": "^2.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
@ -1954,6 +1955,11 @@
|
||||
"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": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
|
@ -17,6 +17,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.1.6",
|
||||
"ical.js": "^2.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
|
@ -10,12 +10,15 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
@ -48,9 +51,9 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// 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 {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@ -120,9 +123,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
httpError(w, err.Error())
|
||||
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 {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
e := bytes.NewReader(raw)
|
||||
|
||||
msg, err := enmime.ReadEnvelope(e)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
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
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
|
||||
start = 0
|
||||
limit = 50
|
||||
beforeTS = 0 // timestamp
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -19,13 +19,13 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = storage.Search(search, "", 0, 1)
|
||||
messages, _, err = storage.Search(search, "", 0, 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
messages, err = storage.List(0, 1)
|
||||
messages, err = storage.List(0, 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
|
@ -26,9 +26,10 @@ func sendData(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
}
|
||||
|
||||
// Get the latest 100 messages
|
||||
func getMessages() ([]message, error) {
|
||||
messages := []message{}
|
||||
list, err := storage.List(0, 100)
|
||||
list, err := storage.List(0, 0, 100)
|
||||
if err != nil {
|
||||
return messages, err
|
||||
}
|
||||
|
@ -1,11 +1,18 @@
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue'
|
||||
import mitt from 'mitt';
|
||||
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
|
||||
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.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 {
|
||||
white-space: pre;
|
||||
}
|
||||
@ -266,8 +228,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
#message-page {
|
||||
.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 {
|
||||
@ -320,6 +309,18 @@ body.blur {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
&.read {
|
||||
> div {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#message-view {
|
||||
.form-control.dropdown {
|
||||
padding: 0;
|
||||
|
@ -54,14 +54,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div
|
||||
class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
|
||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||
<button class="text-muted btn btn-sm ps-0" v-on:click="loadInfo()">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</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">
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
@ -152,7 +151,7 @@ export default {
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
v-on:click="loadInfo">
|
||||
v-on:click="loadInfo()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@ -183,8 +182,8 @@ export default {
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||
<small class="text-secondary">
|
||||
({{
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
}})
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
}})
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -142,7 +142,7 @@ export default {
|
||||
</b>
|
||||
</div>
|
||||
<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">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
|
@ -100,7 +100,7 @@ export default {
|
||||
</li>
|
||||
</ul>
|
||||
</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"
|
||||
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
|
@ -7,6 +7,9 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
@ -63,7 +66,7 @@ export default {
|
||||
} else {
|
||||
// update pagination offset
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -91,6 +94,7 @@ export default {
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
@ -100,6 +104,15 @@ export default {
|
||||
if (this.version != response.Data.Version) {
|
||||
location.reload()
|
||||
}
|
||||
} else if (response.Type == "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data);
|
||||
} else if (response.Type == "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data);
|
||||
} else if (response.Type == "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate")
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,6 +164,11 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
handleMessageEvents(message) {
|
||||
console.log("generic")
|
||||
console.log(message)
|
||||
},
|
||||
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0
|
||||
|
@ -403,11 +403,13 @@ export default {
|
||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="d-md-none small">
|
||||
<tr class="small">
|
||||
<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 class="small">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
|
@ -115,8 +115,10 @@ export default {
|
||||
* @params function callback function
|
||||
* @params function error callback function
|
||||
*/
|
||||
get(url, values, callback, errorCallback) {
|
||||
this.loading++
|
||||
get(url, values, callback, errorCallback, hideLoader) {
|
||||
if (!hideLoader) {
|
||||
this.loading++
|
||||
}
|
||||
axios.get(url, { params: values })
|
||||
.then(callback)
|
||||
.catch((err) => {
|
||||
@ -128,7 +130,7 @@ export default {
|
||||
})
|
||||
.then(() => {
|
||||
// always executed
|
||||
if (this.loading > 0) {
|
||||
if (!hideLoader && this.loading > 0) {
|
||||
this.loading--
|
||||
}
|
||||
})
|
||||
@ -273,6 +275,11 @@ export default {
|
||||
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.
|
||||
// Values are stored in an array for faster lookup / processing.
|
||||
colorHash(s) {
|
||||
|
@ -14,6 +14,9 @@ import { pagination } from "../stores/pagination";
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@ -27,6 +30,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
delayedRefresh: false,
|
||||
}
|
||||
},
|
||||
|
||||
@ -40,6 +44,18 @@ export default {
|
||||
mailbox.searching = false
|
||||
this.apiURI = this.resolve(`/api/v1/messages`)
|
||||
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: {
|
||||
@ -55,7 +71,51 @@ export default {
|
||||
}
|
||||
|
||||
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>
|
||||
@ -89,18 +149,24 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<AboutMailpit />
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
|
@ -7,10 +7,14 @@ import Release from '../components/message/Release.vue'
|
||||
import Screenshot from '../components/message/Screenshot.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@ -24,20 +28,105 @@ export default {
|
||||
mailbox,
|
||||
pagination,
|
||||
message: false,
|
||||
prevLink: false,
|
||||
nextLink: false,
|
||||
errorMessage: false,
|
||||
apiSideNavURI: false,
|
||||
apiSideNavParams: URLSearchParams,
|
||||
apiIsMore: true,
|
||||
messagesList: [],
|
||||
scrollLoading: false,
|
||||
canLoadMore: true,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.loadMessage()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
this.initLoadMoreAPIParams()
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadMessage()
|
||||
|
||||
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
|
||||
if (!this.messagesList.length) {
|
||||
this.loadMore()
|
||||
}
|
||||
|
||||
this.refreshUI()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("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: {
|
||||
@ -48,9 +137,8 @@ export default {
|
||||
this.errorMessage = false
|
||||
const d = response.data
|
||||
|
||||
if (this.wasUnread(d.ID)) {
|
||||
mailbox.unread--
|
||||
}
|
||||
// update read status in case websockets is not working
|
||||
this.handleWSUpdate({ 'ID': d.ID, Read: true })
|
||||
|
||||
// replace inline images embedded as inline attachments
|
||||
if (d.HTML && d.Inline) {
|
||||
@ -94,7 +182,9 @@ export default {
|
||||
|
||||
this.message = d
|
||||
|
||||
this.detectPrevNext()
|
||||
this.$nextTick(() => {
|
||||
this.scrollSidebarToCurrent()
|
||||
})
|
||||
},
|
||||
(error) => {
|
||||
this.errorMessage = true
|
||||
@ -114,37 +204,145 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
// try detect whether this message was unread based on messages listing
|
||||
wasUnread(id) {
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == id) {
|
||||
if (!mailbox.messages[m].Read) {
|
||||
mailbox.messages[m].Read = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// UI refresh ticker to adjust relative times
|
||||
refreshUI() {
|
||||
window.setTimeout(() => {
|
||||
this.$forceUpdate()
|
||||
this.refreshUI()
|
||||
}, 30000)
|
||||
},
|
||||
|
||||
// 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() {
|
||||
// generate the prev/next links based on current message list
|
||||
this.prevLink = false
|
||||
this.nextLink = false
|
||||
let found = false
|
||||
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == this.message.ID) {
|
||||
found = true
|
||||
} else if (found && !this.nextLink) {
|
||||
this.nextLink = mailbox.messages[m].ID
|
||||
break
|
||||
} else {
|
||||
this.prevLink = mailbox.messages[m].ID
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
for (let x = 0; x < this.messagesList.length; x++) {
|
||||
if (this.messagesList[x].ID == data.ID) {
|
||||
// remove message from the list
|
||||
this.messagesList.splice(x, 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 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) {
|
||||
const dl = document.createElement('a')
|
||||
dl.href = "data:text/plain," + encodeURIComponent(str)
|
||||
@ -157,25 +355,44 @@ export default {
|
||||
this.$refs.ScreenshotRef.initScreenshot()
|
||||
},
|
||||
|
||||
// mark current message as read
|
||||
markUnread() {
|
||||
// toggle current message read status
|
||||
toggleRead() {
|
||||
if (!this.message) {
|
||||
return false
|
||||
}
|
||||
const read = !this.isRead
|
||||
|
||||
const ids = [this.message.ID]
|
||||
const uri = this.resolve('/api/v1/messages')
|
||||
this.put(uri, { 'read': false, 'ids': [this.message.ID] }, (response) => {
|
||||
this.goBack()
|
||||
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
|
||||
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() {
|
||||
const ids = [this.message.ID]
|
||||
const uri = this.resolve('/api/v1/messages')
|
||||
this.delete(uri, { 'ids': ids }, () => {
|
||||
this.goBack()
|
||||
// calculate next ID before deletion to prevent WS race
|
||||
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() {
|
||||
mailbox.lastMessage = this.$route.params.id
|
||||
|
||||
@ -189,8 +406,7 @@ export default {
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push('/search?' + params.toString())
|
||||
this.$router.push('/search?' + new URLSearchParams(p).toString())
|
||||
} else {
|
||||
const p = {}
|
||||
if (pagination.start > 0) {
|
||||
@ -199,8 +415,7 @@ export default {
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push('/?' + params.toString())
|
||||
this.$router.push('/?' + new URLSearchParams(p).toString())
|
||||
}
|
||||
},
|
||||
|
||||
@ -218,25 +433,26 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 col-auto pe-0">
|
||||
<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">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col col-md-4k col-lg-5 col-xl-6" 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">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()">
|
||||
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
|
||||
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
|
||||
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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@ -297,19 +513,18 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="prevLink ? '' : 'disabled'" title="View previous message">
|
||||
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="previousID ? '' : 'disabled'" title="View previous message">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</RouterLink>
|
||||
<RouterLink :to="'/view/' + 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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
|
||||
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
|
||||
v-if="mailbox.uiConfig.Label">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
@ -318,7 +533,11 @@ export default {
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
<button @click="goBack()" class="list-group-item list-group-item-action">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Return</span>
|
||||
<span class="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"
|
||||
v-if="mailbox.unread && !errorMessage">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
@ -326,24 +545,45 @@ export default {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" v-if="!errorMessage">
|
||||
<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 class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
|
||||
@scroll="scrollHandler">
|
||||
<template v-if="messagesList && messagesList.length">
|
||||
<div class="list-group">
|
||||
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
|
||||
:id="message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action"
|
||||
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
|
||||
<div class="col-12 overflow-x-hidden">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div class="col overflow-x-hidden">
|
||||
<div class="text-truncate privacy small">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</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>
|
||||
|
||||
<AboutMailpit />
|
||||
</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">
|
||||
<template v-if="errorMessage">
|
||||
<h3 class="text-center my-3">
|
||||
|
@ -14,6 +14,9 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@ -28,6 +31,7 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
delayedRefresh: false,
|
||||
}
|
||||
},
|
||||
|
||||
@ -40,6 +44,18 @@ export default {
|
||||
mounted() {
|
||||
mailbox.searching = this.getSearch()
|
||||
this.doSearch()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// unsubscribe from events
|
||||
this.eventBus.off("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -59,7 +75,50 @@ export default {
|
||||
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
}
|
||||
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>
|
||||
@ -93,18 +152,23 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<AboutMailpit />
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user