Skip to content

Commit

Permalink
Merge branch 'feature/tag-filters' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
axllent committed Apr 26, 2024
2 parents 5f2e548 + dddc52a commit 96d0feb
Show file tree
Hide file tree
Showing 11 changed files with 279 additions and 79 deletions.
14 changes: 6 additions & 8 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ func init() {
rootCmd.Flags().StringVar(&config.POP3TLSKey, "pop3-tls-key", config.POP3TLSKey, "Optional TLS key for POP3 server - requires pop3-tls-cert")

// Tagging
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "Convert new tags automatically to TitleCase")
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")

// Webhook
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
Expand Down Expand Up @@ -283,12 +284,9 @@ func initConfigFromEnv() {
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")

// Tagging
if len(os.Getenv("MP_TAG")) > 0 {
config.SMTPCLITags = os.Getenv("MP_TAG")
}
if getEnabledFromEnv("MP_TAGS_TITLE_CASE") {
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
}
config.CLITagsArg = os.Getenv("MP_TAG")
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")

// Webhook
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
Expand Down
44 changes: 16 additions & 28 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -86,14 +85,17 @@ var (
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
BlockRemoteCSSAndFonts = false

// SMTPCLITags is used to map the CLI args
SMTPCLITags string
// CLITagsArg is used to map the CLI args
CLITagsArg string

// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)

// SMTPTags are expressions to apply tags to new mail
SMTPTags []AutoTag
// TagsConfig is a yaml file to pre-load tags
TagsConfig string

// TagFilters are used to apply tags to new mail
TagFilters []autoTag

// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
Expand Down Expand Up @@ -162,9 +164,9 @@ var (
)

// AutoTag struct for auto-tagging
type AutoTag struct {
Tag string
type autoTag struct {
Match string
Tags []string
}

// SMTPRelayConfigStruct struct for parsing yaml & storing variables
Expand Down Expand Up @@ -381,27 +383,13 @@ func VerifyConfig() error {
}
}

SMTPTags = []AutoTag{}

if SMTPCLITags != "" {
args := tools.ArgsParser(SMTPCLITags)

for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
tag := tools.CleanTag(t[0])
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
return fmt.Errorf("[tag] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
}
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
if len(match) == 0 {
return fmt.Errorf("[tag] invalid tag match (%s) - no search detected", tag)
}
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
} else {
return fmt.Errorf("[tag] error parsing tags (%s)", a)
}
}
// load tag filters
TagFilters = []autoTag{}
if err := loadTagsFromArgs(CLITagsArg); err != nil {
return err
}
if err := loadTagsFromConfig(TagsConfig); err != nil {
return err
}

if SMTPAllowedRecipients != "" {
Expand Down
81 changes: 81 additions & 0 deletions config/tags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package config

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)

type yamlTags struct {
Filters []yamlTag `yaml:"filters"`
}

type yamlTag struct {
Match string `yaml:"match"`
Tags string `yaml:"tags"`
}

// Load tags from a configuration from a file, if set
func loadTagsFromConfig(c string) error {
if c == "" {
return nil // not set, ignore
}

c = filepath.Clean(c)

if !isFile(c) {
return fmt.Errorf("[tags] configuration file not found or unreadable: %s", c)
}

data, err := os.ReadFile(c)
if err != nil {
return fmt.Errorf("[tags] %s", err.Error())
}

conf := yamlTags{}

if err := yaml.Unmarshal(data, &conf); err != nil {
return err
}

if conf.Filters == nil {
return fmt.Errorf("[tags] missing tag: array in %s", c)
}

for _, t := range conf.Filters {
tags := strings.Split(t.Tags, ",")
TagFilters = append(TagFilters, autoTag{Match: t.Match, Tags: tags})
}

logger.Log().Debugf("[tags] loaded %s from config %s", tools.Plural(len(conf.Filters), "tag filter", "tag filters"), c)

return nil
}

func loadTagsFromArgs(c string) error {
if c == "" {
return nil // not set, ignore
}

args := tools.ArgsParser(c)

for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
tags := strings.Split(t[0], ",")
TagFilters = append(TagFilters, autoTag{Match: match, Tags: tags})
} else {
return fmt.Errorf("[tag] error parsing tags (%s)", a)
}
}

logger.Log().Debugf("[tags] loaded %s from CLI args", tools.Plural(len(args), "tag filter", "tag filters"))

return nil
}
2 changes: 2 additions & 0 deletions internal/storage/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ func InitDB() error {
return err
}

LoadTagFilters()

dbFile = p
dbLastAction = time.Now()

Expand Down
29 changes: 18 additions & 11 deletions internal/storage/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,6 @@ func Store(body *[]byte) (string, error) {
return "", err
}

// extract tags from body matches based on --tag, plus addresses & X-Tags header
tagStr := findTagsInRawMessage(body) + "," +
obj.tagsFromPlusAddresses() + "," +
strings.TrimSpace(env.Root.Header.Get("X-Tags"))

tagData := uniqueTagsFromString(tagStr)

// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
Expand Down Expand Up @@ -119,9 +112,23 @@ func Store(body *[]byte) (string, error) {
return "", err
}

if len(tagData) > 0 {
// set tags after tx.Commit()
if err := SetMessageTags(id, tagData); err != nil {
// 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...))

if len(tags) > 0 {
if err := SetMessageTags(id, tags); err != nil {
return "", err
}
}
Expand All @@ -137,7 +144,7 @@ func Store(body *[]byte) (string, error) {
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tagData
c.Tags = tags
c.Snippet = snippet

websockets.Broadcast("new", c)
Expand Down
10 changes: 10 additions & 0 deletions internal/storage/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
q.Where("ReplyToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "addressed:") {
w = cleanString(w[10:])
arg := "%" + escPercentChar(w) + "%"
if w != "" {
if exclude {
q.Where("(ToJSON NOT LIKE ? AND FromJSON NOT LIKE ? AND CcJSON NOT LIKE ? AND BccJSON NOT LIKE ? AND ReplyToJSON NOT LIKE ?)", arg, arg, arg, arg, arg)
} else {
q.Where("(ToJSON LIKE ? OR FromJSON LIKE ? OR CcJSON LIKE ? OR BccJSON LIKE ? OR ReplyToJSON LIKE ?)", arg, arg, arg, arg, arg)
}
}
} else if strings.HasPrefix(lw, "subject:") {
w = w[8:]
if w != "" {
Expand Down
84 changes: 84 additions & 0 deletions internal/storage/tagfilters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package storage

import (
"context"
"database/sql"
"strings"

"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
)

// TagFilter struct
type TagFilter struct {
Match string
SQL *sqlf.Stmt
Tags []string
}

var tagFilters = []TagFilter{}

// LoadTagFilters loads tag filters from the config and pre-generates the SQL query
func LoadTagFilters() {
tagFilters = []TagFilter{}

for _, t := range config.TagFilters {
match := strings.TrimSpace(t.Match)
if match == "" {
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
continue
}
if t.Tags == nil || len(t.Tags) == 0 {
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
continue
}

validTags := []string{}
for _, tag := range t.Tags {
tagName := tools.CleanTag(tag)
if !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 {
logger.Log().Warnf("[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tagName)
continue
}
validTags = append(validTags, tagName)
}

if len(validTags) == 0 {
continue
}

tagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, "")})
}
}

// TagFilterMatches returns a slice of matching tags from a message
func tagFilterMatches(id string) []string {
tags := []string{}

if len(tagFilters) == 0 {
return tags
}

for _, f := range tagFilters {
var matchID string
q := f.SQL.Clone().Where("ID = ?", id)
if err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) {
var ignore sql.NullString

if err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return tags
}
if matchID == id {
tags = append(tags, f.Tags...)
}
}

return tags
}
Loading

0 comments on commit 96d0feb

Please sign in to comment.