From 42e6d71415aea6d293a8e592dd1b31a812c1d2fe Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Wed, 31 May 2023 08:20:05 +1200 Subject: [PATCH 1/5] Update API docs --- docs/apiv1/Message.md | 2 ++ docs/apiv1/Messages.md | 3 +++ docs/apiv1/Search.md | 1 + 3 files changed, 6 insertions(+) diff --git a/docs/apiv1/Message.md b/docs/apiv1/Message.md index d40c36e..28f14eb 100644 --- a/docs/apiv1/Message.md +++ b/docs/apiv1/Message.md @@ -15,6 +15,7 @@ Returns a JSON summary of the message and attachments. ```json { "ID": "d7a5543b-96dd-478b-9b60-2b465c9884de", + "MessageID": "12345.67890@localhost", "Read": true, "From": { "Name": "John Doe", @@ -31,6 +32,7 @@ Returns a JSON summary of the message and attachments. "ReplyTo": [], "Subject": "Message subject", "Date": "2016-09-07T16:46:00+13:00", + "Tags": ["test"], "Text": "Plain text MIME part of the email", "HTML": "HTML MIME part (if exists)", "Size": 79499, diff --git a/docs/apiv1/Messages.md b/docs/apiv1/Messages.md index 9d72129..709f9c5 100644 --- a/docs/apiv1/Messages.md +++ b/docs/apiv1/Messages.md @@ -31,9 +31,11 @@ List messages in the mailbox. Messages are returned in the order of latest recei "unread": 500, "count": 50, "start": 0, + "tags": ["test"], "messages": [ { "ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f", + "MessageID": "12345.67890@localhost", "Read": false, "From": { "Name": "John Doe", @@ -54,6 +56,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei "Bcc": [], "Subject": "Message subject", "Created": "2022-10-03T21:35:32.228605299+13:00", + "Tags": ["test"], "Size": 6144, "Attachments": 0 }, diff --git a/docs/apiv1/Search.md b/docs/apiv1/Search.md index fdf6d02..038e55f 100644 --- a/docs/apiv1/Search.md +++ b/docs/apiv1/Search.md @@ -30,6 +30,7 @@ Matching messages are returned in the order of latest received to oldest. "messages": [ { "ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f", + "MessageID": "12345.67890@localhost", "Read": false, "From": { "Name": "John Doe", From 1b47716f5fe74500baec85e9a545fb7bc399ebd7 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Jun 2023 08:28:24 +1200 Subject: [PATCH 2/5] Libs: Update node modules --- package-lock.json | 34 ++++++++++++++--------------- server/ui-src/assets/bootstrap.scss | 1 + 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c7252c..5549f6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,9 +35,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.21.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.9.tgz", - "integrity": "sha512-q5PNg/Bi1OpGgx5jYlvWZwAorZepEudDMCLtj967aeS7WMont7dUZI46M2XwcIQqvUlMxWfdLFu4S/qSxeUu5g==", + "version": "7.22.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.4.tgz", + "integrity": "sha512-VLLsx06XkEYqBtE5YGPwfSGwfrjnyPP5oiGty3S8pQLFDFLaS8VwWSIxkTXpcvr5zeYLE6+MBNl2npl/YnfofA==", "bin": { "parser": "bin/babel-parser.js" }, @@ -46,11 +46,11 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.21.5.tgz", - "integrity": "sha512-FRqFlFKNazWYykft5zvzuEl1YyTDGsIRrjV9rvxvYkUC7W/ueBng1X68Xd6uRMzAaJ0xMKn08/wem5YS1lpX8w==", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.22.3.tgz", + "integrity": "sha512-6bdmknScYKmt8I9VjsJuKKGr+TwUb555FTf6tT1P/ANlCjTHCiYLhiQ4X/O7J731w5NOqu8c1aYHEVuOwPz7jA==", "dependencies": { - "core-js-pure": "^3.25.1", + "core-js-pure": "^3.30.2", "regenerator-runtime": "^0.13.11" }, "engines": { @@ -428,9 +428,9 @@ } }, "node_modules/@popperjs/core": { - "version": "2.11.7", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", - "integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -1029,9 +1029,9 @@ } }, "node_modules/bootstrap": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz", - "integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.0.tgz", + "integrity": "sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==", "funding": [ { "type": "github", @@ -1043,7 +1043,7 @@ } ], "peerDependencies": { - "@popperjs/core": "^2.11.6" + "@popperjs/core": "^2.11.7" } }, "node_modules/bootstrap-icons": { @@ -1965,9 +1965,9 @@ } }, "node_modules/postcss": { - "version": "8.4.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", - "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "version": "8.4.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", + "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", "funding": [ { "type": "opencollective", diff --git a/server/ui-src/assets/bootstrap.scss b/server/ui-src/assets/bootstrap.scss index f4b7977..1992350 100644 --- a/server/ui-src/assets/bootstrap.scss +++ b/server/ui-src/assets/bootstrap.scss @@ -4,6 +4,7 @@ // Configuration @import "../../../node_modules/bootstrap/scss/functions"; @import "../../../node_modules/bootstrap/scss/variables"; +@import "../../../node_modules/bootstrap/scss/variables-dark"; @import "../../../node_modules/bootstrap/scss/maps"; @import "../../../node_modules/bootstrap/scss/mixins"; @import "../../../node_modules/bootstrap/scss/utilities"; From d4268b8ae120901c8010433a21d5632093280db1 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Jun 2023 14:47:36 +1200 Subject: [PATCH 3/5] 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 4/5] 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 {
- + + + + +