diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a787625d..547994043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Notable changes to Mailpit will be documented in this file. +## [v1.19.0] + +### Feature +- Add ability to rename and delete tags globally +- Add option to disable auto-tagging for plus-addresses & X-Tags ([#323](https://github.com/axllent/mailpit/issues/323)) + +### Chore +- Update node dependencies +- Update Go dependencies + + ## [v1.18.7] ### Feature diff --git a/Dockerfile b/Dockerfile index fa7f27fe1..c10460944 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:alpine as builder +FROM golang:alpine AS builder ARG VERSION=dev diff --git a/cmd/root.go b/cmd/root.go index ccdc9502a..a17d16c3e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -131,6 +131,7 @@ func init() { rootCmd.Flags().StringVarP(&config.CLITagsArg, "tag", "t", config.CLITagsArg, "Tag new messages matching filters") rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file") rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags") + rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)") // Webhook rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages") @@ -290,6 +291,7 @@ func initConfigFromEnv() { config.CLITagsArg = os.Getenv("MP_TAG") config.TagsConfig = os.Getenv("MP_TAGS_CONFIG") tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE") + config.TagsDisable = os.Getenv("MP_TAGS_DISABLE") // Webhook if len(os.Getenv("MP_WEBHOOK_URL")) > 0 { diff --git a/config/config.go b/config/config.go index ceb80bf73..c5c914b97 100644 --- a/config/config.go +++ b/config/config.go @@ -102,6 +102,10 @@ var ( // TagFilters are used to apply tags to new mail TagFilters []autoTag + // TagsDisable accepts a comma-separated list of tag types to disable + // including x-tags & plus-addresses + TagsDisable string + // SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server SMTPRelayConfigFile string @@ -390,7 +394,7 @@ func VerifyConfig() error { } } - // load tag filters + // load tag filters & options TagFilters = []autoTag{} if err := loadTagsFromArgs(CLITagsArg); err != nil { return err @@ -398,6 +402,9 @@ func VerifyConfig() error { if err := loadTagsFromConfig(TagsConfig); err != nil { return err } + if err := parseTagsDisable(TagsDisable); err != nil { + return err + } if SMTPAllowedRecipients != "" { restrictRegexp, err := regexp.Compile(SMTPAllowedRecipients) diff --git a/config/tags.go b/config/tags.go index cb46f2599..c11ec26a3 100644 --- a/config/tags.go +++ b/config/tags.go @@ -11,6 +11,14 @@ import ( "gopkg.in/yaml.v3" ) +var ( + // TagsDisablePlus disables message tagging using plus-addresses (user+tag@example.com) - set via verifyConfig() + TagsDisablePlus bool + + // TagsDisableXTags disables message tagging via the X-Tags header - set via verifyConfig() + TagsDisableXTags bool +) + type yamlTags struct { Filters []yamlTag `yaml:"filters"` } @@ -79,3 +87,25 @@ func loadTagsFromArgs(c string) error { return nil } + +func parseTagsDisable(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + + parts := strings.Split(strings.ToLower(s), ",") + + for _, p := range parts { + switch strings.TrimSpace(p) { + case "x-tags", "xtags": + TagsDisableXTags = true + case "plus-addresses", "plus-addressing": + TagsDisablePlus = true + default: + return fmt.Errorf("[tags] invalid --tags-disable option: %s", p) + } + } + + return nil +} diff --git a/go.mod b/go.mod index 647067c18..cb8742213 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/PuerkitoBio/goquery v1.9.2 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/axllent/semver v0.0.1 - github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 + github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 github.com/jhillyerd/enmime v1.2.0 @@ -55,11 +55,11 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/vanng822/css v1.0.1 // indirect golang.org/x/crypto v0.24.0 // indirect - golang.org/x/image v0.17.0 // indirect + golang.org/x/image v0.18.0 // indirect golang.org/x/sys v0.21.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect - modernc.org/libc v1.53.3 // indirect + modernc.org/libc v1.53.4 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect modernc.org/strutil v1.2.0 // indirect diff --git a/go.sum b/go.sum index a19ed5291..7195186b2 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= -github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 h1:yEt5djSYb4iNtmV9iJGVday+i4e9u6Mrn5iP64HH5QM= -github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 h1:saBP362Qm7zDdDXqv61kI4rzhmLFq3Z1gx34xpl6cWE= +github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -128,8 +128,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco= -golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= @@ -197,16 +197,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.3 h1:2mhBdWKtivdFlLR1ecKXTljPG1mfvbByX7QKztAIJl8= modernc.org/cc/v4 v4.21.3/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.18.1 h1:1zF5kPBFq/ZVTulBOKgQPQITdOzzyBUfC51gVYP62E4= -modernc.org/ccgo/v4 v4.18.1/go.mod h1:ao1fAxf9a2KEOL15WY8+yP3wnpaOpP/QuyFOZ9HJolM= +modernc.org/ccgo/v4 v4.18.2 h1:PUQPShG4HwghpOekNujL0sFavdkRvmxzTbI4rGJ5mg0= +modernc.org/ccgo/v4 v4.18.2/go.mod h1:ao1fAxf9a2KEOL15WY8+yP3wnpaOpP/QuyFOZ9HJolM= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.53.3 h1:9O0aSLZuHPgp49we24NoFFteRgXNLGBAQ3TODrW3XLg= -modernc.org/libc v1.53.3/go.mod h1:kb+Erju4FfHNE59xd2fNpv5CBeAeej6fHbx8p8xaiyI= +modernc.org/libc v1.53.4 h1:YAgFS7tGIFBfqje2UOqiXtIwuDUCF8AUonYw0seup34= +modernc.org/libc v1.53.4/go.mod h1:aGsLofnkcct8lTJnKQnCqJO37ERAXSHamSuWLFoF2Cw= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= diff --git a/internal/storage/messages.go b/internal/storage/messages.go index 638267232..d4e139ec6 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -112,23 +112,29 @@ func Store(body *[]byte) (string, error) { return "", err } - // extract tags from body matches - rawTags := findTagsInRawMessage(body) - // extract plus addresses tags from enmime.Envelope - plusTags := obj.tagsFromPlusAddresses() - // extract tags from X-Tags header - xTags := tools.SetTagCasing(strings.Split(strings.TrimSpace(env.Root.Header.Get("X-Tags")), ",")) - // extract tags from search matches - searchTags := tagFilterMatches(id) - - // combine all tags into one slice - tags := append(rawTags, plusTags...) - tags = append(tags, xTags...) - // sort and extract only unique tags - tags = sortedUniqueTags(append(tags, searchTags...)) + // extract tags using pre-set tag filters, empty slice if not set + tags := findTagsInRawMessage(body) + + if !config.TagsDisableXTags { + xTagsHdr := env.Root.Header.Get("X-Tags") + if xTagsHdr != "" { + // extract tags from X-Tags header + tags = append(tags, tools.SetTagCasing(strings.Split(strings.TrimSpace(xTagsHdr), ","))...) + } + } + + if !config.TagsDisablePlus { + // get tags from plus-addresses + tags = append(tags, obj.tagsFromPlusAddresses()...) + } + + // extract tags from search matches, and sort and extract unique tags + tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...)) + setTags := []string{} if len(tags) > 0 { - if err := SetMessageTags(id, tags); err != nil { + setTags, err = SetMessageTags(id, tags) + if err != nil { return "", err } } @@ -144,7 +150,7 @@ func Store(body *[]byte) (string, error) { c.Attachments = attachments c.Subject = subject c.Size = size - c.Tags = tags + c.Tags = setTags c.Snippet = snippet websockets.Broadcast("new", c) @@ -593,7 +599,9 @@ func DeleteMessages(ids []string) error { } } - err = tx.Commit() + if err := tx.Commit(); err != nil { + return err + } dbLastAction = time.Now() addDeletedSize(int64(totalSize)) diff --git a/internal/storage/schemas.go b/internal/storage/schemas.go index 874e45c54..aaf26080f 100644 --- a/internal/storage/schemas.go +++ b/internal/storage/schemas.go @@ -137,7 +137,9 @@ func dbApplySchemas() error { buf := new(bytes.Buffer) - err = t1.Execute(buf, nil) + if err := t1.Execute(buf, nil); err != nil { + return err + } if _, err := db.Exec(buf.String()); err != nil { return err @@ -197,7 +199,7 @@ func migrateTagsToManyMany() { if len(toConvert) > 0 { logger.Log().Infof("[migration] converting %d message tags", len(toConvert)) for id, tags := range toConvert { - if err := SetMessageTags(id, tags); err != nil { + if _, err := SetMessageTags(id, tags); err != nil { logger.Log().Errorf("[migration] %s", err.Error()) } else { if _, err := sqlf.Update(tenant("mailbox")). diff --git a/internal/storage/tags.go b/internal/storage/tags.go index 9facfc4fe..c87189270 100644 --- a/internal/storage/tags.go +++ b/internal/storage/tags.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "database/sql" + "fmt" "regexp" "sort" "strings" @@ -21,7 +22,7 @@ var ( ) // SetMessageTags will set the tags for a given database ID, removing any not in the array -func SetMessageTags(id string, tags []string) error { +func SetMessageTags(id string, tags []string) ([]string, error) { applyTags := []string{} for _, t := range tags { t = tools.CleanTag(t) @@ -30,6 +31,7 @@ func SetMessageTags(id string, tags []string) error { } } + tagNames := []string{} currentTags := getMessageTags(id) origTagCount := len(currentTags) @@ -38,9 +40,12 @@ func SetMessageTags(id string, tags []string) error { continue } - if err := AddMessageTag(id, t); err != nil { - return err + name, err := AddMessageTag(id, t) + if err != nil { + return []string{}, err } + + tagNames = append(tagNames, name) } if origTagCount > 0 { @@ -49,42 +54,44 @@ func SetMessageTags(id string, tags []string) error { for _, t := range currentTags { if !tools.InArray(t, applyTags) { if err := DeleteMessageTag(id, t); err != nil { - return err + return []string{}, err } } } } - return nil + return tagNames, nil } // AddMessageTag adds a tag to a message -func AddMessageTag(id, name string) error { +func AddMessageTag(id, name string) (string, error) { // prevent two identical tags being added at the same time addTagMutex.Lock() var tagID int + var foundName sql.NullString q := sqlf.From(tenant("tags")). Select("ID").To(&tagID). + Select("Name").To(&foundName). Where("Name = ?", name) // if tag exists - add tag to message if err := q.QueryRowAndClose(context.TODO(), db); err == nil { addTagMutex.Unlock() // check message does not already have this tag - var count int + var exists int if err := sqlf.From(tenant("message_tags")). - Select("COUNT(ID)").To(&count). + Select("COUNT(ID)").To(&exists). Where("ID = ?", id). Where("TagID = ?", tagID). QueryRowAndClose(context.Background(), db); err != nil { - return err + return "", err } - if count > 0 { + if exists > 0 { // already exists - return nil + return foundName.String, nil } logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id) @@ -93,7 +100,7 @@ func AddMessageTag(id, name string) error { Set("ID", id). Set("TagID", tagID). ExecAndClose(context.TODO(), db) - return err + return foundName.String, err } // new tag, add to the database @@ -101,7 +108,7 @@ func AddMessageTag(id, name string) error { Set("Name", name). ExecAndClose(context.TODO(), db); err != nil { addTagMutex.Unlock() - return err + return name, err } addTagMutex.Unlock() @@ -174,6 +181,79 @@ func GetAllTagsCount() map[string]int64 { return tags } +// RenameTag renames a tag +func RenameTag(from, to string) error { + to = tools.CleanTag(to) + if to == "" || !config.ValidTagRegexp.MatchString(to) { + return fmt.Errorf("invalid tag name: %s", to) + } + + if from == to { + return nil // ignore + } + + var id, existsID int + + q := sqlf.From(tenant("tags")). + Select(`ID`).To(&id). + Where(`Name = ?`, from). + Limit(1) + err := q.QueryRowAndClose(context.Background(), db) + if err != nil { + return fmt.Errorf("tag not found: %s", from) + } + + // check if another tag by this name already exists + q = sqlf.From(tenant("tags")). + Select("ID").To(&existsID). + Where(`Name = ?`, to). + Where(`ID != ?`, id). + Limit(1) + err = q.QueryRowAndClose(context.Background(), db) + if err == nil || existsID != 0 { + return fmt.Errorf("tag already exists: %s", to) + } + + q = sqlf.Update(tenant("tags")). + Set("Name", to). + Where("ID = ?", id) + _, err = q.ExecAndClose(context.Background(), db) + + return err +} + +// DeleteTag deleted a tag and removed all references to the tag +func DeleteTag(tag string) error { + var id int + + q := sqlf.From(tenant("tags")). + Select(`ID`).To(&id). + Where(`Name = ?`, tag). + Limit(1) + err := q.QueryRowAndClose(context.Background(), db) + if err != nil { + return fmt.Errorf("tag not found: %s", tag) + } + + // delete all references + q = sqlf.DeleteFrom(tenant("message_tags")). + Where(`TagID = ?`, id) + _, err = q.ExecAndClose(context.Background(), db) + if err != nil { + return fmt.Errorf("error deleting tag references: %s", err.Error()) + } + + // delete tag + q = sqlf.DeleteFrom(tenant("tags")). + Where(`ID = ?`, id) + _, err = q.ExecAndClose(context.Background(), db) + if err != nil { + return fmt.Errorf("error deleting tag: %s", err.Error()) + } + + return nil +} + // PruneUnusedTags will delete all unused tags from the database func pruneUnusedTags() error { q := sqlf.From(tenant("tags")). diff --git a/internal/storage/tags_test.go b/internal/storage/tags_test.go index 388fe8b93..7b3636a58 100644 --- a/internal/storage/tags_test.go +++ b/internal/storage/tags_test.go @@ -24,7 +24,7 @@ func TestTags(t *testing.T) { } for i := 0; i < 10; i++ { - if err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil { + if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil { t.Log("error ", err) t.Fail() } @@ -58,7 +58,7 @@ func TestTags(t *testing.T) { // pad number with 0 to ensure they are returned alphabetically newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i)) } - if err := SetMessageTags(id, newTags); err != nil { + if _, err := SetMessageTags(id, newTags); err != nil { t.Log("error ", err) t.Fail() } @@ -82,7 +82,7 @@ func TestTags(t *testing.T) { assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty") // apply the same tag twice - if err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil { + if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil { t.Log("error ", err) t.Fail() } @@ -94,7 +94,7 @@ func TestTags(t *testing.T) { } // apply tag with invalid characters - if err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil { + if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil { t.Log("error ", err) t.Fail() } diff --git a/package-lock.json b/package-lock.json index a3d91465b..86abc2f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -938,36 +938,36 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, "node_modules/@vue/compiler-core": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.29.tgz", - "integrity": "sha512-TFKiRkKKsRCKvg/jTSSKK7mYLJEQdUiUfykbG49rubC9SfDyvT2JrzTReopWlz2MxqeLyxh9UZhvxEIBgAhtrg==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.31.tgz", + "integrity": "sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==", "dependencies": { "@babel/parser": "^7.24.7", - "@vue/shared": "3.4.29", + "@vue/shared": "3.4.31", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.29.tgz", - "integrity": "sha512-A6+iZ2fKIEGnfPJejdB7b1FlJzgiD+Y/sxxKwJWg1EbJu6ZPgzaPQQ51ESGNv0CP6jm6Z7/pO6Ia8Ze6IKrX7w==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.31.tgz", + "integrity": "sha512-wK424WMXsG1IGMyDGyLqB+TbmEBFM78hIsOJ9QwUVLGrcSk0ak6zYty7Pj8ftm7nEtdU/DGQxAXp0/lM/2cEpQ==", "dependencies": { - "@vue/compiler-core": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-core": "3.4.31", + "@vue/shared": "3.4.31" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.29.tgz", - "integrity": "sha512-zygDcEtn8ZimDlrEQyLUovoWgKQic6aEQqRXce2WXBvSeHbEbcAsXyCk9oG33ZkyWH4sl9D3tkYc1idoOkdqZQ==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.31.tgz", + "integrity": "sha512-einJxqEw8IIJxzmnxmJBuK2usI+lJonl53foq+9etB2HAzlPjAS/wa7r0uUpXw5ByX3/0uswVSrjNb17vJm1kQ==", "dependencies": { "@babel/parser": "^7.24.7", - "@vue/compiler-core": "3.4.29", - "@vue/compiler-dom": "3.4.29", - "@vue/compiler-ssr": "3.4.29", - "@vue/shared": "3.4.29", + "@vue/compiler-core": "3.4.31", + "@vue/compiler-dom": "3.4.31", + "@vue/compiler-ssr": "3.4.31", + "@vue/shared": "3.4.31", "estree-walker": "^2.0.2", "magic-string": "^0.30.10", "postcss": "^8.4.38", @@ -975,12 +975,12 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.29.tgz", - "integrity": "sha512-rFbwCmxJ16tDp3N8XCx5xSQzjhidYjXllvEcqX/lopkoznlNPz3jyy0WGJCyhAaVQK677WWFt3YO/WUEkMMUFQ==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.31.tgz", + "integrity": "sha512-RtefmITAje3fJ8FSg1gwgDhdKhZVntIVbwupdyZDSifZTRMiWxWehAOTCc8/KZDnBOcYQ4/9VWxsTbd3wT0hAA==", "dependencies": { - "@vue/compiler-dom": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-dom": "3.4.31", + "@vue/shared": "3.4.31" } }, "node_modules/@vue/devtools-api": { @@ -989,49 +989,49 @@ "integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==" }, "node_modules/@vue/reactivity": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.29.tgz", - "integrity": "sha512-w8+KV+mb1a8ornnGQitnMdLfE0kXmteaxLdccm2XwdFxXst4q/Z7SEboCV5SqJNpZbKFeaRBBJBhW24aJyGINg==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.31.tgz", + "integrity": "sha512-VGkTani8SOoVkZNds1PfJ/T1SlAIOf8E58PGAhIOUDYPC4GAmFA2u/E14TDAFcf3vVDKunc4QqCe/SHr8xC65Q==", "dependencies": { - "@vue/shared": "3.4.29" + "@vue/shared": "3.4.31" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.29.tgz", - "integrity": "sha512-s8fmX3YVR/Rk5ig0ic0NuzTNjK2M7iLuVSZyMmCzN/+Mjuqqif1JasCtEtmtoJWF32pAtUjyuT2ljNKNLeOmnQ==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.31.tgz", + "integrity": "sha512-LDkztxeUPazxG/p8c5JDDKPfkCDBkkiNLVNf7XZIUnJ+66GVGkP+TIh34+8LtPisZ+HMWl2zqhIw0xN5MwU1cw==", "dependencies": { - "@vue/reactivity": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/reactivity": "3.4.31", + "@vue/shared": "3.4.31" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.29.tgz", - "integrity": "sha512-gI10atCrtOLf/2MPPMM+dpz3NGulo9ZZR9d1dWo4fYvm+xkfvRrw1ZmJ7mkWtiJVXSsdmPbcK1p5dZzOCKDN0g==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.31.tgz", + "integrity": "sha512-2Auws3mB7+lHhTFCg8E9ZWopA6Q6L455EcU7bzcQ4x6Dn4cCPuqj6S2oBZgN2a8vJRS/LSYYxwFFq2Hlx3Fsaw==", "dependencies": { - "@vue/reactivity": "3.4.29", - "@vue/runtime-core": "3.4.29", - "@vue/shared": "3.4.29", + "@vue/reactivity": "3.4.31", + "@vue/runtime-core": "3.4.31", + "@vue/shared": "3.4.31", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.29.tgz", - "integrity": "sha512-HMLCmPI2j/k8PVkSBysrA2RxcxC5DgBiCdj7n7H2QtR8bQQPqKAe8qoaxLcInzouBmzwJ+J0x20ygN/B5mYBng==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.31.tgz", + "integrity": "sha512-D5BLbdvrlR9PE3by9GaUp1gQXlCNadIZytMIb8H2h3FMWJd4oUfkUTEH2wAr3qxoRz25uxbTcbqd3WKlm9EHQA==", "dependencies": { - "@vue/compiler-ssr": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-ssr": "3.4.31", + "@vue/shared": "3.4.31" }, "peerDependencies": { - "vue": "3.4.29" + "vue": "3.4.31" } }, "node_modules/@vue/shared": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz", - "integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA==" + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.31.tgz", + "integrity": "sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA==" }, "node_modules/anymatch": { "version": "3.1.3", @@ -1759,12 +1759,15 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", + "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", "dev": true, "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2029,9 +2032,12 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3038,15 +3044,15 @@ "peer": true }, "node_modules/vue": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.29.tgz", - "integrity": "sha512-8QUYfRcYzNlYuzKPfge1UWC6nF9ym0lx7mpGVPJYNhddxEf3DD0+kU07NTL0sXuiT2HuJuKr/iEO8WvXvT0RSQ==", + "version": "3.4.31", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.31.tgz", + "integrity": "sha512-njqRrOy7W3YLAlVqSKpBebtZpDVg21FPoaq1I7f/+qqBThK9ChAIjkRWgeP6Eat+8C+iia4P3OYqpATP21BCoQ==", "dependencies": { - "@vue/compiler-dom": "3.4.29", - "@vue/compiler-sfc": "3.4.29", - "@vue/runtime-dom": "3.4.29", - "@vue/server-renderer": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-dom": "3.4.31", + "@vue/compiler-sfc": "3.4.31", + "@vue/runtime-dom": "3.4.31", + "@vue/server-renderer": "3.4.31", + "@vue/shared": "3.4.31" }, "peerDependencies": { "typescript": "*" @@ -3066,9 +3072,9 @@ } }, "node_modules/vue-router": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.3.tgz", - "integrity": "sha512-8Q+u+WP4N2SXY38FDcF2H1dUEbYVHVPtPCPZj/GTZx8RCbiB8AtJP9+YIxn4Vs0svMTNQcLIzka4GH7Utkx9xQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.0.tgz", + "integrity": "sha512-HB+t2p611aIZraV2aPSRNXf0Z/oLZFrlygJm+sZbdJaW6lcFqEDQwnzUBXn+DApw+/QzDU/I9TeWx9izEjTmsA==", "dependencies": { "@vue/devtools-api": "^6.5.1" }, diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 905bd5791..80888c0fd 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -523,77 +523,6 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) } -// GetAllTags (method: GET) will get all tags currently in use -func GetAllTags(w http.ResponseWriter, _ *http.Request) { - // swagger:route GET /api/v1/tags tags GetAllTags - // - // # Get all current tags - // - // Returns a JSON array of all unique message tags. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Responses: - // 200: ArrayResponse - // default: ErrorResponse - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil { - httpError(w, err.Error()) - } -} - -// SetMessageTags (method: PUT) will set the tags for all provided IDs -func SetMessageTags(w http.ResponseWriter, r *http.Request) { - // swagger:route PUT /api/v1/tags tags SetTags - // - // # Set message tags - // - // This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array. - // - // Consumes: - // - application/json - // - // Produces: - // - text/plain - // - // Schemes: http, https - // - // Responses: - // 200: OKResponse - // default: ErrorResponse - - decoder := json.NewDecoder(r.Body) - - var data struct { - Tags []string - IDs []string - } - - err := decoder.Decode(&data) - if err != nil { - httpError(w, err.Error()) - return - } - - ids := data.IDs - - if len(ids) > 0 { - for _, id := range ids { - if err := storage.SetMessageTags(id, data.Tags); err != nil { - httpError(w, err.Error()) - return - } - } - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - // ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server. func ReleaseMessage(w http.ResponseWriter, r *http.Request) { // swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage diff --git a/server/apiv1/swagger.go b/server/apiv1/swagger.go index b0c5c3005..d9977965c 100644 --- a/server/apiv1/swagger.go +++ b/server/apiv1/swagger.go @@ -95,6 +95,22 @@ type setTagsRequestBody struct { IDs []string } +// swagger:parameters RenameTag +type renameTagParams struct { + // in: body + Body *renameTagRequestBody +} + +// Rename tag request +// swagger:model renameTagRequestBody +type renameTagRequestBody struct { + // New name + // + // required: true + // example: New name + Name string +} + // swagger:parameters ReleaseMessage type releaseMessageParams struct { // Message database ID diff --git a/server/apiv1/tags.go b/server/apiv1/tags.go new file mode 100644 index 000000000..f9e154b13 --- /dev/null +++ b/server/apiv1/tags.go @@ -0,0 +1,171 @@ +package apiv1 + +import ( + "encoding/json" + "net/http" + + "github.com/axllent/mailpit/internal/storage" + "github.com/axllent/mailpit/server/websockets" + "github.com/gorilla/mux" +) + +// GetAllTags (method: GET) will get all tags currently in use +func GetAllTags(w http.ResponseWriter, _ *http.Request) { + // swagger:route GET /api/v1/tags tags GetAllTags + // + // # Get all current tags + // + // Returns a JSON array of all unique message tags. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: ArrayResponse + // default: ErrorResponse + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil { + httpError(w, err.Error()) + } +} + +// SetMessageTags (method: PUT) will set the tags for all provided IDs +func SetMessageTags(w http.ResponseWriter, r *http.Request) { + // swagger:route PUT /api/v1/tags tags SetTags + // + // # Set message tags + // + // This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array. + // + // Consumes: + // - application/json + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Responses: + // 200: OKResponse + // default: ErrorResponse + + decoder := json.NewDecoder(r.Body) + + var data struct { + Tags []string + IDs []string + } + + err := decoder.Decode(&data) + if err != nil { + httpError(w, err.Error()) + return + } + + ids := data.IDs + + if len(ids) > 0 { + for _, id := range ids { + if _, err := storage.SetMessageTags(id, data.Tags); err != nil { + httpError(w, err.Error()) + return + } + } + } + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} + +// RenameTag (method: PUT) used to rename a tag +func RenameTag(w http.ResponseWriter, r *http.Request) { + // swagger:route PUT /api/v1/tags/{tag} tags RenameTag + // + // # Rename a tag + // + // Renames a tag. + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Parameters: + // + name: tag + // in: path + // description: The url-encoded tag name to rename + // required: true + // type: string + // + // Responses: + // 200: OKResponse + // default: ErrorResponse + + vars := mux.Vars(r) + + tag := vars["tag"] + + decoder := json.NewDecoder(r.Body) + + var data struct { + Name string + } + + err := decoder.Decode(&data) + if err != nil { + httpError(w, err.Error()) + return + } + + if err := storage.RenameTag(tag, data.Name); err != nil { + httpError(w, err.Error()) + return + } + + websockets.Broadcast("prune", nil) + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} + +// DeleteTag (method: DELETE) used to delete a tag +func DeleteTag(w http.ResponseWriter, r *http.Request) { + // swagger:route DELETE /api/v1/tags/{tag} tags DeleteTag + // + // # Delete a tag + // + // Deletes a tag. This will not delete any messages with this tag. + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Parameters: + // + name: tag + // in: path + // description: The url-encoded tag name to delete + // required: true + // type: string + // + // Responses: + // 200: OKResponse + // default: ErrorResponse + + vars := mux.Vars(r) + + tag := vars["tag"] + + if err := storage.DeleteTag(tag); err != nil { + httpError(w, err.Error()) + return + } + + websockets.Broadcast("prune", nil) + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} diff --git a/server/pop3/pop3_test.go b/server/pop3/pop3_test.go index 912ab3a30..9012becdd 100644 --- a/server/pop3/pop3_test.go +++ b/server/pop3/pop3_test.go @@ -67,7 +67,7 @@ func TestPOP3(t *testing.T) { return } - count, size, err = c.Stat() + count, _, err = c.Stat() if err != nil { t.Errorf(err.Error()) return @@ -349,7 +349,7 @@ func insertEmailData(t *testing.T) { t.Fail() } - if err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil { + if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil { t.Log("error ", err) t.Fail() } diff --git a/server/server.go b/server/server.go index 8ea160db9..aee94d59d 100644 --- a/server/server.go +++ b/server/server.go @@ -132,6 +132,8 @@ func apiRoutes() *mux.Router { r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST") r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT") + r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT") + r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag)).Methods("DELETE") r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET") diff --git a/server/server_test.go b/server/server_test.go index b39b0a63c..da9a53e34 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -383,7 +383,7 @@ func insertEmailData(t *testing.T) { t.Fail() } - if err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil { + if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil { t.Log("error ", err) t.Fail() } diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue index 35e336a3e..913f4db6b 100644 --- a/server/ui-src/App.vue +++ b/server/ui-src/App.vue @@ -2,6 +2,7 @@ import CommonMixins from './mixins/CommonMixins' import Favicon from './components/Favicon.vue' import Notifications from './components/Notifications.vue' +import EditTags from './components/EditTags.vue' import { RouterView } from 'vue-router' import { mailbox } from "./stores/mailbox" @@ -11,6 +12,7 @@ export default { components: { Favicon, Notifications, + EditTags }, beforeMount() { @@ -41,4 +43,5 @@ export default { + diff --git a/server/ui-src/components/EditTags.vue b/server/ui-src/components/EditTags.vue new file mode 100644 index 000000000..4824dc030 --- /dev/null +++ b/server/ui-src/components/EditTags.vue @@ -0,0 +1,119 @@ + + + diff --git a/server/ui-src/components/NavTags.vue b/server/ui-src/components/NavTags.vue index 89122d5f5..94b2d091b 100644 --- a/server/ui-src/components/NavTags.vue +++ b/server/ui-src/components/NavTags.vue @@ -86,6 +86,11 @@ export default { Tags