diff --git a/CHANGELOG.md b/CHANGELOG.md index 73672dc..83f2ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Notable changes to Mailpit will be documented in this file. +## v1.2.8 + +### Bugfix +- Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments + +### Feature +- Message tags and auto-tagging + + ## v1.2.7 ### Feature diff --git a/README.md b/README.md index f03a1ec..1a44ca9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster. - SMTP server (default `0.0.0.0:1025`) - Web UI to view emails (formatted HTML, highlighted HTML source, text, raw source and MIME attachments including image thumbnails) - Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search)) +- Message tagging ([see wiki](https://github.com/axllent/mailpit/wiki/Tagging)) - Real-time web UI updates using web sockets for new mail - Optional browser notifications for new mail (HTTPS only) - Configurable automatic email pruning (default keeps the most recent 500 emails) @@ -30,7 +31,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster. - Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication)) - Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS)) - Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication)) -- A simple REST API allowing ([see docs](docs/apiv1/README.md)) +- A simple REST API ([see docs](docs/apiv1/README.md)) - Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images) @@ -42,7 +43,7 @@ Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with: sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh) ``` -Or download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options, or see [the wiki](https://github.com/axllent/mailpit/wiki/Runtime-options) for additional information. +Or download a static binary from the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options, or see [the wiki](https://github.com/axllent/mailpit/wiki/Runtime-options) for additional information. To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source). diff --git a/cmd/root.go b/cmd/root.go index bab5887..1950199 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -85,6 +85,9 @@ func init() { if len(os.Getenv("MP_MAX_MESSAGES")) > 0 { config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES")) } + if len(os.Getenv("MP_TAG")) > 0 { + config.SMTPCLITags = os.Getenv("MP_TAG") + } if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 { config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE") } @@ -139,6 +142,7 @@ func init() { rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication") rootCmd.Flags().StringVar(&config.SMTPSSLCert, "smtp-ssl-cert", config.SMTPSSLCert, "SSL certificate for SMTP - requires smtp-ssl-key") rootCmd.Flags().StringVar(&config.SMTPSSLKey, "smtp-ssl-key", config.SMTPSSLKey, "SSL key for SMTP - requires smtp-ssl-cert") + rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", "", "Tag new messages matching filters") rootCmd.Flags().BoolVarP(&config.QuietLogging, "quiet", "q", false, "Quiet logging (errors only)") rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging") diff --git a/config/config.go b/config/config.go index 639b2ec..f249763 100644 --- a/config/config.go +++ b/config/config.go @@ -3,12 +3,13 @@ package config import ( "errors" "fmt" - "net/url" "os" + "path" "path/filepath" "regexp" "strings" + "github.com/mattn/go-shellwords" "github.com/tg123/go-htpasswd" ) @@ -61,6 +62,15 @@ var ( // SMTPAuth used for euthentication SMTPAuth *htpasswd.File + // SMTPCLITags is used to map the CLI args + SMTPCLITags string + + // TagRegexp is the allowed tag characters + TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`) + + // SMTPTags are expressions to apply tags to new mail + SMTPTags []Tag + // ContentSecurityPolicy for HTTP server ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';" @@ -74,6 +84,12 @@ var ( RepoBinaryName = "mailpit" ) +// Tag struct +type Tag struct { + Tag string + Match string +} + // VerifyConfig wil do some basic checking func VerifyConfig() error { if DataFile != "" && isDir(DataFile) { @@ -148,12 +164,38 @@ func VerifyConfig() error { return fmt.Errorf("Webroot cannot contain spaces (%s)", Webroot) } - s, err := url.JoinPath("/", Webroot, "/") - if err != nil { - return err - } + s := path.Join("/", Webroot, "/") Webroot = s + SMTPTags = []Tag{} + + p := shellwords.NewParser() + + if SMTPCLITags != "" { + args, err := p.Parse(SMTPCLITags) + if err != nil { + return fmt.Errorf("Error parsing tags (%s)", err) + } + + for _, a := range args { + t := strings.Split(a, "=") + if len(t) > 1 { + tag := strings.TrimSpace(t[0]) + if !TagRegexp.MatchString(tag) || len(tag) == 0 { + return fmt.Errorf("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("Invalid tag match (%s) - no search detected", tag) + } + SMTPTags = append(SMTPTags, Tag{Tag: tag, Match: match}) + } else { + return fmt.Errorf("Error parsing tags (%s)", a) + } + } + + } + return nil } diff --git a/docs/apiv1/Message.md b/docs/apiv1/Message.md index ff0a74f..5cf47b5 100644 --- a/docs/apiv1/Message.md +++ b/docs/apiv1/Message.md @@ -26,8 +26,8 @@ Returns a JSON summary of the message and attachments. "Address": "jane@example.com" } ], - "Cc": null, - "Bcc": null, + "Cc": [], + "Bcc": [], "Subject": "Message subject", "Date": "2016-09-07T16:46:00+13:00", "Text": "Plain text MIME part of the email", @@ -57,7 +57,7 @@ Returns a JSON summary of the message and attachments. - `Read` - always true (message marked read on open) - `From` - Name & Address, or null -- `To`, `CC`, `BCC` - Array of Names & Address, or null +- `To`, `CC`, `BCC` - Array of Names & Address - `Date` - Parsed email local date & time from headers - `Size` - Total size of raw email - `Inline`, `Attachments` - Array of attachments and inline images. diff --git a/docs/apiv1/Messages.md b/docs/apiv1/Messages.md index a4d5aea..9d72129 100644 --- a/docs/apiv1/Messages.md +++ b/docs/apiv1/Messages.md @@ -51,7 +51,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei "Address": "accounts@example.com" } ], - "Bcc": null, + "Bcc": [], "Subject": "Message subject", "Created": "2022-10-03T21:35:32.228605299+13:00", "Size": 6144, @@ -70,7 +70,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei - `start` - The offset (default `0`) for pagination - `Read` - The read/unread status of the message - `From` - Name & Address, or null if none -- `To`, `CC`, `BCC` - Array of Names & Address, or null if none +- `To`, `CC`, `BCC` - Array of Names & Address - `Created` - Local date & time the message was received - `Size` - Total size of raw email in bytes diff --git a/docs/apiv1/README.md b/docs/apiv1/README.md index 3b6a133..309a032 100644 --- a/docs/apiv1/README.md +++ b/docs/apiv1/README.md @@ -8,4 +8,5 @@ The API is split into three main parts: - [Messages](Messages.md) - Listing, deleting & marking messages as read/unread. - [Message](Message.md) - Return message data & attachments +- [Tags](Tags.md) - Set message tags - [Search](Search.md) - Searching messages diff --git a/docs/apiv1/Search.md b/docs/apiv1/Search.md index f2c7aa2..fdf6d02 100644 --- a/docs/apiv1/Search.md +++ b/docs/apiv1/Search.md @@ -47,7 +47,7 @@ Matching messages are returned in the order of latest received to oldest. "Address": "accounts@example.com" } ], - "Bcc": null, + "Bcc": [], "Subject": "Test email", "Created": "2022-10-03T21:35:32.228605299+13:00", "Size": 6144, @@ -65,5 +65,5 @@ Matching messages are returned in the order of latest received to oldest. - `count` - Number of messages returned in request - `start` - The offset (default `0`) for pagination - `From` - Singular Name & Address, or null if none -- `To`, `CC`, `BCC` - Array of Name & Address, or null if none +- `To`, `CC`, `BCC` - Array of Name & Address - `Size` - Total size of raw email in bytes diff --git a/docs/apiv1/Tags.md b/docs/apiv1/Tags.md new file mode 100644 index 0000000..a1e6c6c --- /dev/null +++ b/docs/apiv1/Tags.md @@ -0,0 +1,27 @@ +# Tags + +Set message tags. + + +--- +## Update message tags + +Set the tags for one or more messages. +If the tags array is empty then all tags are removed from the messages. + +**URL** : `api/v1/tags` + +**Method** : `PUT` + +### Request + +```json +{ + "ids": ["",""...], + "tags": ["",""] +} +``` + +### Response + +**Status** : `200` diff --git a/docs/screenshot.png b/docs/screenshot.png index 55339e4..1e65e4a 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/package-lock.json b/package-lock.json index 7349966..b837f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "axios": "^0.27.2", "bootstrap": "^5.2.0", "bootstrap-icons": "^1.9.1", + "bootstrap5-tags": "^1.4.41", "moment": "^2.29.4", "prismjs": "^1.29.0", "tinycon": "^0.6.8", @@ -237,6 +238,11 @@ "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.9.1.tgz", "integrity": "sha512-d4ZkO30MIkAhQ2nNRJqKXJVEQorALGbLWTuRxyCTJF96lRIV6imcgMehWGJUiJMJhglN0o2tqLIeDnMdiQEE9g==" }, + "node_modules/bootstrap5-tags": { + "version": "1.4.42", + "resolved": "https://registry.npmjs.org/bootstrap5-tags/-/bootstrap5-tags-1.4.42.tgz", + "integrity": "sha512-JqENAkPxdgcGVFQsELhW0ULmpUe4Unhrl+5WOMaZbXpWg6EsaY/SNlygWdOL66U5V0UOnKFD2rni/PzjANVyNA==" + }, "node_modules/braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", @@ -1601,6 +1607,11 @@ "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.9.1.tgz", "integrity": "sha512-d4ZkO30MIkAhQ2nNRJqKXJVEQorALGbLWTuRxyCTJF96lRIV6imcgMehWGJUiJMJhglN0o2tqLIeDnMdiQEE9g==" }, + "bootstrap5-tags": { + "version": "1.4.42", + "resolved": "https://registry.npmjs.org/bootstrap5-tags/-/bootstrap5-tags-1.4.42.tgz", + "integrity": "sha512-JqENAkPxdgcGVFQsELhW0ULmpUe4Unhrl+5WOMaZbXpWg6EsaY/SNlygWdOL66U5V0UOnKFD2rni/PzjANVyNA==" + }, "braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", diff --git a/package.json b/package.json index 6a7040a..4aca909 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "axios": "^0.27.2", "bootstrap": "^5.2.0", "bootstrap-icons": "^1.9.1", + "bootstrap5-tags": "^1.4.41", "moment": "^2.29.4", "prismjs": "^1.29.0", "tinycon": "^0.6.8", diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 9665440..b2653bd 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -1,6 +1,7 @@ package apiv1 import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -32,6 +33,7 @@ func GetMessages(w http.ResponseWriter, r *http.Request) { res.Count = len(messages) res.Total = stats.Total res.Unread = stats.Unread + res.Tags = stats.Tags bytes, _ := json.Marshal(res) w.Header().Add("Content-Type", "application/json") @@ -63,6 +65,7 @@ func Search(w http.ResponseWriter, r *http.Request) { res.Count = len(messages) res.Total = stats.Total res.Unread = stats.Unread + res.Tags = stats.Tags bytes, _ := json.Marshal(res) w.Header().Add("Content-Type", "application/json") @@ -120,7 +123,7 @@ func Headers(w http.ResponseWriter, r *http.Request) { return } - reader := strings.NewReader(string(data)) + reader := bytes.NewReader(data) m, err := mail.ReadMessage(reader) if err != nil { httpError(w, err.Error()) @@ -234,6 +237,36 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) } +// SetTags (method: PUT) will set the tags for all provided IDs +func SetTags(w http.ResponseWriter, r *http.Request) { + 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.SetTags(id, data.Tags); err != nil { + httpError(w, err.Error()) + return + } + } + } + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} + // FourOFour returns a basic 404 message func fourOFour(w http.ResponseWriter) { w.Header().Set("Referrer-Policy", "no-referrer") diff --git a/server/apiv1/structs.go b/server/apiv1/structs.go index 25031a0..e04bb4c 100644 --- a/server/apiv1/structs.go +++ b/server/apiv1/structs.go @@ -14,6 +14,7 @@ type MessagesSummary struct { Unread int `json:"unread"` Count int `json:"count"` Start int `json:"start"` + Tags []string `json:"tags"` Messages []MessageSummary `json:"messages"` } diff --git a/server/server.go b/server/server.go index 3d16568..021c468 100644 --- a/server/server.go +++ b/server/server.go @@ -67,6 +67,7 @@ func defaultRoutes() *mux.Router { r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT") r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE") + r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetTags)).Methods("PUT") r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET") 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") diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue index 3c1a04c..0059838 100644 --- a/server/ui-src/App.vue +++ b/server/ui-src/App.vue @@ -1,6 +1,7 @@ + + diff --git a/server/ui-src/templates/MessageTags.vue b/server/ui-src/templates/MessageTags.vue new file mode 100644 index 0000000..5420cae --- /dev/null +++ b/server/ui-src/templates/MessageTags.vue @@ -0,0 +1,72 @@ + + + + diff --git a/storage/database.go b/storage/database.go index 1945259..db7e4e3 100644 --- a/storage/database.go +++ b/storage/database.go @@ -14,6 +14,7 @@ import ( "path" "path/filepath" "regexp" + "sort" "strings" "syscall" "time" @@ -65,6 +66,12 @@ var ( ); CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`, }, + { + Version: 1.1, + Description: "Create tags column", + Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]'; + CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`, + }, } ) @@ -200,7 +207,17 @@ func Store(body []byte) (string, error) { // generate unique ID id := uuid.NewV4().String() - b, err := json.Marshal(obj) + summaryJSON, err := json.Marshal(obj) + if err != nil { + return "", err + } + + tagData := findTags(&body) + + tagJSON, err := json.Marshal(tagData) + if err != nil { + return "", err + } // begin a transaction to ensure both the message // and data are stored successfully @@ -213,8 +230,8 @@ func Store(body []byte) (string, error) { // roll back if it fails defer tx.Rollback() - // insert summary - _, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Read) values(?,?,?, 0)", id, string(b), searchText) + // insert mail summary data + _, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Tags, Read) values(?,?,?,?,0)", id, string(summaryJSON), searchText, string(tagJSON)) if err != nil { return "", err } @@ -231,10 +248,12 @@ func Store(body []byte) (string, error) { } c := &MessageSummary{} - if err := json.Unmarshal(b, c); err != nil { + if err := json.Unmarshal(summaryJSON, c); err != nil { return "", err } + c.Tags = tagData + c.ID = id websockets.Broadcast("new", c) @@ -250,7 +269,7 @@ func List(start, limit int) ([]MessageSummary, error) { results := []MessageSummary{} q := sqlf.From("mailbox"). - Select(`ID, Data, Read`). + Select(`ID, Data, Tags, Read`). OrderBy("Sort DESC"). Limit(limit). Offset(start) @@ -258,16 +277,21 @@ func List(start, limit int) ([]MessageSummary, error) { if err := q.QueryAndClose(nil, db, func(row *sql.Rows) { var id string var summary string + var tags string var read int em := MessageSummary{} - if err := row.Scan(&id, &summary, &read); err != nil { + if err := row.Scan(&id, &summary, &tags, &read); err != nil { logger.Log().Error(err) return } - err := json.Unmarshal([]byte(summary), &em) - if err != nil { + if err := json.Unmarshal([]byte(summary), &em); err != nil { + logger.Log().Error(err) + return + } + + if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil { logger.Log().Error(err) return } @@ -313,17 +337,22 @@ func Search(search string, start, limit int) ([]MessageSummary, error) { if err := q.QueryAndClose(nil, db, func(row *sql.Rows) { var id string var summary string + var tags string var read int var ignore string em := MessageSummary{} - if err := row.Scan(&id, &summary, &read, &ignore, &ignore, &ignore, &ignore); err != nil { + if err := row.Scan(&id, &summary, &tags, &read, &ignore, &ignore, &ignore, &ignore); err != nil { logger.Log().Error(err) return } - err := json.Unmarshal([]byte(summary), &em) - if err != nil { + if err := json.Unmarshal([]byte(summary), &em); err != nil { + logger.Log().Error(err) + return + } + + if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil { logger.Log().Error(err) return } @@ -378,6 +407,7 @@ func GetMessage(id string) (*Message, error) { Cc: addressToSlice(env, "Cc"), Bcc: addressToSlice(env, "Bcc"), Subject: env.GetHeader("Subject"), + Tags: getMessageTags(id), Size: len(raw), Text: env.Text, } @@ -386,6 +416,8 @@ func GetMessage(id string) (*Message, error) { var re = regexp.MustCompile(`(?U)`) html := re.ReplaceAllString(env.HTML, "") obj.HTML = html + obj.Inline = []Attachment{} + obj.Attachments = []Attachment{} for _, i := range env.Inlines { if i.FileName != "" || i.ContentID != "" { @@ -656,9 +688,42 @@ func StatsGet() MailboxStats { dbLastAction = time.Now() + q := sqlf.From("mailbox"). + Select(`DISTINCT Tags`). + Where("Tags != ?", "[]") + + var tags = []string{} + + if err := q.QueryAndClose(nil, db, func(row *sql.Rows) { + var tagData string + t := []string{} + + if err := row.Scan(&tagData); err != nil { + logger.Log().Error(err) + return + } + + if err := json.Unmarshal([]byte(tagData), &t); err != nil { + logger.Log().Error(err) + return + } + + for _, tag := range t { + if !inArray(tag, tags) { + tags = append(tags, tag) + } + } + + }); err != nil { + logger.Log().Error(err) + } + + sort.Strings(tags) + return MailboxStats{ Total: total, Unread: unread, + Tags: tags, } } diff --git a/storage/search.go b/storage/search.go index 42c2eaa..dbe679d 100644 --- a/storage/search.go +++ b/storage/search.go @@ -14,7 +14,7 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt { } q := sqlf.From("mailbox"). - Select(`ID, Data, read, + Select(`ID, Data, Tags, Read, json_extract(Data, '$.To') as ToJSON, json_extract(Data, '$.From') as FromJSON, json_extract(Data, '$.Subject') as Subject, @@ -72,6 +72,15 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt { q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%") } } + } else if strings.HasPrefix(w, "tag:") { + w = cleanString(w[4:]) + if w != "" { + if exclude { + q.Where("Tags NOT LIKE ?", "%\""+escPercentChar(w)+"\"%") + } else { + q.Where("Tags LIKE ?", "%\""+escPercentChar(w)+"\"%") + } + } } else if w == "is:read" { if exclude { q.Where("Read = 0") diff --git a/storage/structs.go b/storage/structs.go index 7cc68ff..545dfb1 100644 --- a/storage/structs.go +++ b/storage/structs.go @@ -17,6 +17,7 @@ type Message struct { Bcc []*mail.Address Subject string Date time.Time + Tags []string Text string HTML string Size int @@ -43,6 +44,7 @@ type MessageSummary struct { Bcc []*mail.Address Subject string Created time.Time + Tags []string Size int Attachments int } @@ -51,6 +53,7 @@ type MessageSummary struct { type MailboxStats struct { Total int Unread int + Tags []string } // AttachmentSummary returns a summary of the attachment without any binary data diff --git a/storage/tags.go b/storage/tags.go new file mode 100644 index 0000000..d9cdf2c --- /dev/null +++ b/storage/tags.go @@ -0,0 +1,86 @@ +package storage + +import ( + "context" + "encoding/json" + "regexp" + "sort" + "strings" + + "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/utils/logger" + "github.com/leporo/sqlf" +) + +// SetTags will set the tags for a given message ID, used via API +func SetTags(id string, tags []string) error { + applyTags := []string{} + reg := regexp.MustCompile(`\s+`) + for _, t := range tags { + t = strings.TrimSpace(reg.ReplaceAllString(t, " ")) + + if t != "" && config.TagRegexp.MatchString(t) && !inArray(t, applyTags) { + applyTags = append(applyTags, t) + } + } + + tagJSON, err := json.Marshal(applyTags) + if err != nil { + logger.Log().Errorf("[db] setting tags for message %s", id) + return err + } + + _, err = sqlf.Update("mailbox"). + Set("Tags", string(tagJSON)). + Where("ID = ?", id). + ExecAndClose(context.Background(), db) + + if err == nil { + logger.Log().Debugf("[db] set tags %s for message %s", string(tagJSON), id) + } + + return err +} + +// Used to auto-apply tags to new messages +func findTags(message *[]byte) []string { + tags := []string{} + if len(config.SMTPTags) == 0 { + return tags + } + + str := strings.ToLower(string(*message)) + for _, t := range config.SMTPTags { + if !inArray(t.Tag, tags) && strings.Contains(str, t.Match) { + tags = append(tags, t.Tag) + } + } + + sort.Strings(tags) + + return tags +} + +// Get message tags from the database for a given message ID. +// Used when parsing a raw email. +func getMessageTags(id string) []string { + tags := []string{} + var data string + + q := sqlf.From("mailbox"). + Select(`Tags`).To(&data). + Where(`ID = ?`, id) + + err := q.QueryRowAndClose(context.Background(), db) + if err != nil { + logger.Log().Error(err) + return tags + } + + if err := json.Unmarshal([]byte(data), &tags); err != nil { + logger.Log().Error(err) + return tags + } + + return tags +} diff --git a/storage/utils.go b/storage/utils.go index 73dd5a3..3e2d294 100644 --- a/storage/utils.go +++ b/storage/utils.go @@ -19,7 +19,10 @@ import ( // Return a header field as a []*mail.Address, or "null" is not found/empty func addressToSlice(env *enmime.Envelope, key string) []*mail.Address { - data, _ := env.AddressList(key) + data, err := env.AddressList(key) + if err != nil || data == nil { + return []*mail.Address{} + } return data } @@ -159,6 +162,17 @@ func isFile(path string) bool { return true } +func inArray(k string, arr []string) bool { + k = strings.ToLower(k) + for _, v := range arr { + if strings.ToLower(v) == k { + return true + } + } + + return false +} + // escPercentChar replaces `%` with `%%` for SQL searches func escPercentChar(s string) string { return strings.ReplaceAll(s, "%", "%%")