From d4268b8ae120901c8010433a21d5632093280db1 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Jun 2023 14:47:36 +1200 Subject: [PATCH 1/2] Feature: Set tags via X-Tags message header @see #119 --- config/config.go | 13 ++++++----- storage/database.go | 19 +++++++++------ storage/tags.go | 53 +++++++++++++++++++++++++++++++----------- storage/utils.go | 3 ++- utils/tools/message.go | 4 ++-- utils/tools/tags.go | 25 ++++++++++++++++++++ 6 files changed, 87 insertions(+), 30 deletions(-) create mode 100644 utils/tools/tags.go diff --git a/config/config.go b/config/config.go index 26b32f1..744b3ff 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/axllent/mailpit/utils/logger" + "github.com/axllent/mailpit/utils/tools" "github.com/mattn/go-shellwords" "github.com/tg123/go-htpasswd" "gopkg.in/yaml.v3" @@ -41,7 +42,7 @@ var ( // UIAuthFile for basic authentication UIAuthFile string - // UIAuth used for euthentication + // UIAuth used for authentication UIAuth *htpasswd.File // Webroot to define the base path for the UI and API @@ -71,8 +72,8 @@ var ( // SMTPCLITags is used to map the CLI args SMTPCLITags string - // TagRegexp is the allowed tag characters - TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`) + // ValidTagRegexp represents a valid tag + ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`) // SMTPTags are expressions to apply tags to new mail SMTPTags []AutoTag @@ -86,7 +87,7 @@ var ( // ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile ReleaseEnabled = false - // SMTPRelayAllIncoming is whether to relay all incoming messages via preconfgured SMTP server. + // SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server. // Use with extreme caution! SMTPRelayAllIncoming = false @@ -219,8 +220,8 @@ func VerifyConfig() error { for _, a := range args { t := strings.Split(a, "=") if len(t) > 1 { - tag := strings.TrimSpace(t[0]) - if !TagRegexp.MatchString(tag) || len(tag) == 0 { + tag := tools.CleanTag(t[0]) + if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 { return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag) } match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "="))) diff --git a/storage/database.go b/storage/database.go index 5b50932..192f149 100644 --- a/storage/database.go +++ b/storage/database.go @@ -117,10 +117,6 @@ type DBMailSummary struct { To []*mail.Address Cc []*mail.Address Bcc []*mail.Address - // Subject string - // Size int - // Inline int - // Attachments int } // InitDB will initialise the database @@ -255,7 +251,16 @@ func Store(body []byte) (string, error) { return "", err } - tagData := findTags(&body) + // extract tags from body matches based on --tag + tagStr := findTagsInRawMessage(&body) + + // extract tags from X-Tags header + headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags")) + if headerTags != "" { + tagStr += "," + headerTags + } + + tagData := uniqueTagsFromString(tagStr) tagJSON, err := json.Marshal(tagData) if err != nil { @@ -376,7 +381,7 @@ func List(start, limit int) ([]MessageSummary, error) { } // Search will search a mailbox for search terms. -// The search is broken up by segments (exact phrases can be quoted), and interprits 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:, from: & subject: // Negative searches also also included by prefixing the search term with a `-` or `!` func Search(search string, start, limit int) ([]MessageSummary, error) { @@ -886,7 +891,7 @@ func IsUnread(id string) bool { return unread == 1 } -// MessageIDExists blaah +// MessageIDExists checks whether a Message-ID exists in the DB func MessageIDExists(id string) bool { var total int diff --git a/storage/tags.go b/storage/tags.go index e304335..4c47506 100644 --- a/storage/tags.go +++ b/storage/tags.go @@ -3,23 +3,21 @@ package storage import ( "context" "encoding/json" - "regexp" "sort" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/utils/logger" + "github.com/axllent/mailpit/utils/tools" "github.com/leporo/sqlf" ) // SetTags will set the tags for a given database ID, used via API func SetTags(id string, tags []string) error { applyTags := []string{} - reg := regexp.MustCompile(`\s+`) for _, t := range tags { - t = strings.TrimSpace(reg.ReplaceAllString(t, " ")) - - if t != "" && config.TagRegexp.MatchString(t) && !inArray(t, applyTags) { + t = tools.CleanTag(t) + if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) { applyTags = append(applyTags, t) } } @@ -42,23 +40,22 @@ func SetTags(id string, tags []string) error { return err } -// Used to auto-apply tags to new messages -func findTags(message *[]byte) []string { - tags := []string{} +// Find tags set via --tags in raw message. +// Returns a comma-separated string. +func findTagsInRawMessage(message *[]byte) string { + tagStr := "" if len(config.SMTPTags) == 0 { - return tags + return tagStr } str := strings.ToLower(string(*message)) for _, t := range config.SMTPTags { - if !inArray(t.Tag, tags) && strings.Contains(str, t.Match) { - tags = append(tags, t.Tag) + if strings.Contains(str, t.Match) { + tagStr += "," + t.Tag } } - sort.Strings(tags) - - return tags + return tagStr } // Get message tags from the database for a given database ID @@ -84,3 +81,31 @@ func getMessageTags(id string) []string { return tags } + +// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags +func uniqueTagsFromString(s string) []string { + tags := []string{} + + if s == "" { + return tags + } + + parts := strings.Split(s, ",") + for _, p := range parts { + w := tools.CleanTag(p) + if w == "" { + continue + } + if config.ValidTagRegexp.MatchString(w) { + if !inArray(w, tags) { + tags = append(tags, w) + } + } else { + logger.Log().Debugf("[db] ignoring invalid tag: %s", w) + } + } + + sort.Strings(tags) + + return tags +} diff --git a/storage/utils.go b/storage/utils.go index fb23bc7..2cff91e 100644 --- a/storage/utils.go +++ b/storage/utils.go @@ -75,7 +75,7 @@ func dbCron() { time.Sleep(60 * time.Second) start := time.Now() - // check if database contains deleted data and has not beein in use + // check if database contains deleted data and has not been in use // for 5 minutes, if so VACUUM currentTime := time.Now() diff := currentTime.Sub(dbLastAction) @@ -167,6 +167,7 @@ func isFile(path string) bool { return true } +// InArray tests if a string in within an array. It is not case sensitive. func inArray(k string, arr []string) bool { k = strings.ToLower(k) for _, v := range arr { diff --git a/utils/tools/message.go b/utils/tools/message.go index bd78243..5af68b1 100644 --- a/utils/tools/message.go +++ b/utils/tools/message.go @@ -1,4 +1,4 @@ -// Package tools provides various methods for variouws things +// Package tools provides various methods for various things package tools import ( @@ -23,7 +23,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) { reBlank := regexp.MustCompile(`^\s+`) for _, hdr := range headers { - // case-insentitive + // case-insensitive reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":")) // header := []byte(hdr + ":") diff --git a/utils/tools/tags.go b/utils/tools/tags.go new file mode 100644 index 0000000..660501e --- /dev/null +++ b/utils/tools/tags.go @@ -0,0 +1,25 @@ +package tools + +import ( + "regexp" + "strings" +) + +var ( + // Invalid tag characters regex + tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`) + + // Regex to catch multiple spaces + multiSpaceRe = regexp.MustCompile(`(\s+)`) +) + +// CleanTag returns a clean tag, removing whitespace and invalid characters +func CleanTag(s string) string { + s = strings.TrimSpace( + multiSpaceRe.ReplaceAllString( + tagsInvalidChars.ReplaceAllString(s, " "), + " ", + ), + ) + return s +} From 4a88d1fc244518ffa96862d2c656439348a981c7 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Jun 2023 17:17:54 +1200 Subject: [PATCH 2/2] Feature: Add ability to delete or mark search results read @see #119 --- server/ui-src/App.vue | 136 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 8 deletions(-) diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue index 4bfd9a5..1432b01 100644 --- a/server/ui-src/App.vue +++ b/server/ui-src/App.vue @@ -74,6 +74,13 @@ export default { }, canNext: function () { return this.total > (this.start + this.count); + }, + unreadInSearch: function () { + if (!this.searching) { + return false; + } + + return this.items.filter(i => !i.Read).length; } }, @@ -304,6 +311,24 @@ export default { }); }, + // delete messages displayed in current search + deleteSearch: function () { + let ids = this.items.map(item => item.ID); + + if (!ids.length) { + return false; + } + + let self = this; + let uri = 'api/v1/messages'; + self.delete(uri, { 'ids': ids }, function (response) { + window.location.hash = ""; + self.scrollInPlace = true; + self.loadMessages(); + }); + }, + + // delete all messages from mailbox deleteAll: function () { let self = this; let uri = 'api/v1/messages'; @@ -313,6 +338,7 @@ export default { }); }, + // mark current message as read markUnread: function () { let self = this; if (!self.message) { @@ -326,6 +352,7 @@ export default { }); }, + // mark all messages in mailbox as read markAllRead: function () { let self = this; let uri = 'api/v1/messages' @@ -336,6 +363,24 @@ export default { }); }, + // mark messages in current search as read + markSearchRead: function () { + let ids = this.items.map(item => item.ID); + + if (!ids.length) { + return false; + } + + let self = this; + let uri = 'api/v1/messages'; + self.put(uri, { 'read': true, 'ids': ids }, function (response) { + window.location.hash = ""; + self.scrollInPlace = true; + self.loadMessages(); + }); + }, + + // mark selected messages as read markSelectedRead: function () { let self = this; if (!self.selected.length) { @@ -349,6 +394,7 @@ export default { }); }, + // mark selected messages as unread markSelectedUnread: function () { let self = this; if (!self.selected.length) { @@ -362,7 +408,7 @@ export default { }); }, - // test of any selected emails are unread + // test if any selected emails are unread selectedHasUnread: function () { if (!this.selected.length) { return false; @@ -709,7 +755,8 @@ export default { Mailpit
- +
@@ -720,14 +767,29 @@ export default {
- + + + + +