mirror of
https://github.com/axllent/mailpit.git
synced 2025-08-15 20:13:16 +02:00
Merge branch 'release/v1.19.0'
This commit is contained in:
11
CHANGELOG.md
11
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
|
||||
|
@@ -1,4 +1,4 @@
|
||||
FROM golang:alpine as builder
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
6
go.mod
6
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
|
||||
|
16
go.sum
16
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=
|
||||
|
@@ -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)
|
||||
// extract tags using pre-set tag filters, empty slice if not set
|
||||
tags := findTagsInRawMessage(body)
|
||||
|
||||
// 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...))
|
||||
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))
|
||||
|
@@ -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")).
|
||||
|
@@ -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")).
|
||||
|
@@ -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()
|
||||
}
|
||||
|
132
package-lock.json
generated
132
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
171
server/apiv1/tags.go
Normal file
171
server/apiv1/tags.go
Normal file
@@ -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"))
|
||||
}
|
@@ -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()
|
||||
}
|
||||
|
@@ -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")
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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 {
|
||||
<RouterView />
|
||||
<Favicon />
|
||||
<Notifications />
|
||||
<EditTags />
|
||||
</template>
|
||||
|
119
server/ui-src/components/EditTags.vue
Normal file
119
server/ui-src/components/EditTags.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
editableTags: [],
|
||||
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/),
|
||||
tagToDelete: false,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
'mailbox.tags': {
|
||||
handler(tags) {
|
||||
this.editableTags = []
|
||||
tags.forEach((t) => {
|
||||
this.editableTags.push({ before: t, after: t })
|
||||
})
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
validTag(t) {
|
||||
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lower = t.after.toLowerCase()
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
renameTag(t) {
|
||||
if (!this.validTag(t) || t.before == t.after) {
|
||||
return
|
||||
}
|
||||
|
||||
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
|
||||
// the API triggers a reload via websockets
|
||||
})
|
||||
},
|
||||
|
||||
deleteTag() {
|
||||
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
|
||||
// the API triggers a reload via websockets
|
||||
this.tagToDelete = false
|
||||
})
|
||||
},
|
||||
|
||||
resetTagEdit(t) {
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) {
|
||||
this.editableTags[x].after = this.editableTags[x].before
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag
|
||||
itself, and not any messages which had the tag.
|
||||
</p>
|
||||
<div class="mb-3" v-for="t in editableTags">
|
||||
<div class="input-group has-validation">
|
||||
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''"
|
||||
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required
|
||||
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before"
|
||||
@focus="resetTagEdit(t)">
|
||||
<button v-if="t.before != t.after" class="btn btn-success"
|
||||
@click="renameTag(t)">Save</button>
|
||||
<template v-else>
|
||||
<button class="btn btn-outline-danger"
|
||||
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
|
||||
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false">
|
||||
<template v-if="tagToDelete == t">
|
||||
Confirm?
|
||||
</template>
|
||||
<template v-else>
|
||||
Delete
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
<div class="invalid-feedback">
|
||||
Invalid tag name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@@ -86,6 +86,11 @@ export default {
|
||||
Tags
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
|
||||
Edit tags
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
|
||||
<template v-if="mailbox.showTagColors">Hide</template>
|
||||
|
@@ -703,6 +703,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/tags/{tag}": {
|
||||
"put": {
|
||||
"description": "Renames a tag.",
|
||||
"produces": [
|
||||
"text/plain"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"tags"
|
||||
],
|
||||
"summary": "Rename a tag",
|
||||
"operationId": "RenameTag",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/renameTagRequestBody"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The url-encoded tag name to rename",
|
||||
"name": "tag",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/OKResponse"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes a tag. This will not delete any messages with this tag.",
|
||||
"produces": [
|
||||
"text/plain"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"tags"
|
||||
],
|
||||
"summary": "Delete a tag",
|
||||
"operationId": "DeleteTag",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The url-encoded tag name to delete",
|
||||
"name": "tag",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/OKResponse"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/webui": {
|
||||
"get": {
|
||||
"description": "Returns configuration settings for the web UI.\nIntended for web UI only!",
|
||||
@@ -1690,6 +1763,21 @@
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"renameTagRequestBody": {
|
||||
"description": "Rename tag request",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Name"
|
||||
],
|
||||
"properties": {
|
||||
"Name": {
|
||||
"description": "New name",
|
||||
"type": "string",
|
||||
"example": "New name"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"setReadStatusRequestBody": {
|
||||
"description": "Set read status request",
|
||||
"type": "object",
|
||||
|
Reference in New Issue
Block a user