1
0
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:
Ralph Slooten
2024-08-04 17:04:14 +12:00
parent 54e0c32948
commit a1cb0af639
21 changed files with 636 additions and 162 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' } : ''"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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