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 {