From 9670183d0f5343f7cf1fc5981e4df107626b0ede Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sun, 28 Jul 2024 10:59:02 +1200 Subject: [PATCH 01/14] Fix: Prevent Vue race condition to initialize dayjs relativeTime plugin --- server/ui-src/components/ListMessages.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/ui-src/components/ListMessages.vue b/server/ui-src/components/ListMessages.vue index 66f20d9f4..de0da9325 100644 --- a/server/ui-src/components/ListMessages.vue +++ b/server/ui-src/components/ListMessages.vue @@ -20,9 +20,12 @@ export default { } }, - mounted() { + created() { const relativeTime = require('dayjs/plugin/relativeTime') dayjs.extend(relativeTime) + }, + + mounted() { this.refreshUI() }, From 54e0c32948881547e8ed6809580f91616e60f533 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Aug 2024 16:11:03 +1200 Subject: [PATCH 02/14] Fix(API): Return `text/plain` header for message delete request --- server/apiv1/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 7c55e0aa8..513b6a7d1 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -442,7 +442,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) { } } - w.Header().Add("Content-Type", "application/plain") + w.Header().Add("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) } From a1cb0af639ee71cb3376aeac7c44c264abfa995e Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sun, 4 Aug 2024 17:04:14 +1200 Subject: [PATCH 03/14] Feature(UI): List messages in side nav when viewing message for easy navigation (#336) --- internal/storage/messages.go | 36 +- internal/storage/messages_test.go | 2 +- internal/storage/search.go | 7 +- internal/storage/search_test.go | 12 +- internal/storage/tags.go | 7 + package-lock.json | 6 + package.json | 1 + server/apiv1/api.go | 36 +- server/handlers/messages.go | 4 +- server/pop3/functions.go | 3 +- server/ui-src/app.js | 7 + server/ui-src/assets/styles.scss | 81 ++-- server/ui-src/components/AboutMailpit.vue | 13 +- server/ui-src/components/ListMessages.vue | 2 +- server/ui-src/components/NavTags.vue | 2 +- server/ui-src/components/Notifications.vue | 20 +- server/ui-src/components/message/Message.vue | 8 +- server/ui-src/mixins/CommonMixins.js | 13 +- server/ui-src/views/MailboxView.vue | 84 ++++- server/ui-src/views/MessageView.vue | 370 +++++++++++++++---- server/ui-src/views/SearchView.vue | 82 +++- 21 files changed, 635 insertions(+), 161 deletions(-) diff --git a/internal/storage/messages.go b/internal/storage/messages.go index d4e139ec6..841b2873b 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -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 } diff --git a/internal/storage/messages_test.go b/internal/storage/messages_test.go index b088bd137..95f3525e1 100644 --- a/internal/storage/messages_test.go +++ b/internal/storage/messages_test.go @@ -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() diff --git a/internal/storage/search.go b/internal/storage/search.go index e3242c816..bc018efd2 100644 --- a/internal/storage/search.go +++ b/internal/storage/search.go @@ -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:, from: & subject: // 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) { diff --git a/internal/storage/search_test.go b/internal/storage/search_test.go index cfaa32713..74065a942 100644 --- a/internal/storage/search_test.go +++ b/internal/storage/search_test.go @@ -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() diff --git a/internal/storage/tags.go b/internal/storage/tags.go index c87189270..1a943c6a8 100644 --- a/internal/storage/tags.go +++ b/internal/storage/tags.go @@ -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 } diff --git a/package-lock.json b/package-lock.json index fde6e5c08..f96c23275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index be6f5a10c..96b008953 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 513b6a7d1..14821f924 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -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 diff --git a/server/handlers/messages.go b/server/handlers/messages.go index fd2fc8c13..70de82d07 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -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 diff --git a/server/pop3/functions.go b/server/pop3/functions.go index 8fe7f9155..2ccc45d73 100644 --- a/server/pop3/functions.go +++ b/server/pop3/functions.go @@ -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 } diff --git a/server/ui-src/app.js b/server/ui-src/app.js index 614f92d31..c33a0ed38 100644 --- a/server/ui-src/app.js +++ b/server/ui-src/app.js @@ -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') diff --git a/server/ui-src/assets/styles.scss b/server/ui-src/assets/styles.scss index 29b10afa9..cbe1b35e7 100644 --- a/server/ui-src/assets/styles.scss +++ b/server/ui-src/assets/styles.scss @@ -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; diff --git a/server/ui-src/components/AboutMailpit.vue b/server/ui-src/components/AboutMailpit.vue index fb9f2ed3e..afc7f4eb5 100644 --- a/server/ui-src/components/AboutMailpit.vue +++ b/server/ui-src/components/AboutMailpit.vue @@ -54,14 +54,13 @@ export default {