diff --git a/internal/storage/messages.go b/internal/storage/messages.go index a703f08..d4e139e 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -131,8 +131,10 @@ func Store(body *[]byte) (string, error) { // 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 } } @@ -148,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) diff --git a/internal/storage/schemas.go b/internal/storage/schemas.go index 7b68678..aaf2608 100644 --- a/internal/storage/schemas.go +++ b/internal/storage/schemas.go @@ -199,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 9facfc4..c871892 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 388fe8b..7b3636a 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/server/apiv1/api.go b/server/apiv1/api.go index 905bd57..f57a78e 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -583,7 +583,7 @@ func SetMessageTags(w http.ResponseWriter, r *http.Request) { if len(ids) > 0 { for _, id := range ids { - if err := storage.SetMessageTags(id, data.Tags); err != nil { + if _, err := storage.SetMessageTags(id, data.Tags); err != nil { httpError(w, err.Error()) return } diff --git a/server/apiv1/swagger.go b/server/apiv1/swagger.go index b0c5c30..d997796 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 0000000..7eef448 --- /dev/null +++ b/server/apiv1/tags.go @@ -0,0 +1,100 @@ +package apiv1 + +import ( + "encoding/json" + "net/http" + + "github.com/axllent/mailpit/internal/storage" + "github.com/axllent/mailpit/server/websockets" + "github.com/gorilla/mux" +) + +// 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 dd1672c..9012bec 100644 --- a/server/pop3/pop3_test.go +++ b/server/pop3/pop3_test.go @@ -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 8ea160d..aee94d5 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 b39b0a6..da9a53e 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 35e336a..913f4db 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 0000000..4824dc0 --- /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 89122d5..94b2d09 100644 --- a/server/ui-src/components/NavTags.vue +++ b/server/ui-src/components/NavTags.vue @@ -86,6 +86,11 @@ export default { Tags