mirror of
https://github.com/axllent/mailpit.git
synced 2025-03-17 21:18:19 +02:00
Merge branch 'feature/tags' into develop
This commit is contained in:
commit
8ff2a5cf6a
@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/mattn/go-shellwords"
|
||||
"github.com/tg123/go-htpasswd"
|
||||
"gopkg.in/yaml.v3"
|
||||
@ -41,7 +42,7 @@ var (
|
||||
// UIAuthFile for basic authentication
|
||||
UIAuthFile string
|
||||
|
||||
// UIAuth used for euthentication
|
||||
// UIAuth used for authentication
|
||||
UIAuth *htpasswd.File
|
||||
|
||||
// Webroot to define the base path for the UI and API
|
||||
@ -71,8 +72,8 @@ var (
|
||||
// SMTPCLITags is used to map the CLI args
|
||||
SMTPCLITags string
|
||||
|
||||
// TagRegexp is the allowed tag characters
|
||||
TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
// ValidTagRegexp represents a valid tag
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
|
||||
// SMTPTags are expressions to apply tags to new mail
|
||||
SMTPTags []AutoTag
|
||||
@ -86,7 +87,7 @@ var (
|
||||
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
|
||||
ReleaseEnabled = false
|
||||
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via preconfgured SMTP server.
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
|
||||
// Use with extreme caution!
|
||||
SMTPRelayAllIncoming = false
|
||||
|
||||
@ -219,8 +220,8 @@ func VerifyConfig() error {
|
||||
for _, a := range args {
|
||||
t := strings.Split(a, "=")
|
||||
if len(t) > 1 {
|
||||
tag := strings.TrimSpace(t[0])
|
||||
if !TagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
tag := tools.CleanTag(t[0])
|
||||
if !ValidTagRegexp.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:], "=")))
|
||||
|
@ -74,6 +74,13 @@ export default {
|
||||
},
|
||||
canNext: function () {
|
||||
return this.total > (this.start + this.count);
|
||||
},
|
||||
unreadInSearch: function () {
|
||||
if (!this.searching) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.items.filter(i => !i.Read).length;
|
||||
}
|
||||
},
|
||||
|
||||
@ -304,6 +311,24 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// delete messages displayed in current search
|
||||
deleteSearch: function () {
|
||||
let ids = this.items.map(item => item.ID);
|
||||
|
||||
if (!ids.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let self = this;
|
||||
let uri = 'api/v1/messages';
|
||||
self.delete(uri, { 'ids': ids }, function (response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// delete all messages from mailbox
|
||||
deleteAll: function () {
|
||||
let self = this;
|
||||
let uri = 'api/v1/messages';
|
||||
@ -313,6 +338,7 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// mark current message as read
|
||||
markUnread: function () {
|
||||
let self = this;
|
||||
if (!self.message) {
|
||||
@ -326,6 +352,7 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// mark all messages in mailbox as read
|
||||
markAllRead: function () {
|
||||
let self = this;
|
||||
let uri = 'api/v1/messages'
|
||||
@ -336,6 +363,24 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// mark messages in current search as read
|
||||
markSearchRead: function () {
|
||||
let ids = this.items.map(item => item.ID);
|
||||
|
||||
if (!ids.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let self = this;
|
||||
let uri = 'api/v1/messages';
|
||||
self.put(uri, { 'read': true, 'ids': ids }, function (response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// mark selected messages as read
|
||||
markSelectedRead: function () {
|
||||
let self = this;
|
||||
if (!self.selected.length) {
|
||||
@ -349,6 +394,7 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread: function () {
|
||||
let self = this;
|
||||
if (!self.selected.length) {
|
||||
@ -362,7 +408,7 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// test of any selected emails are unread
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread: function () {
|
||||
if (!this.selected.length) {
|
||||
return false;
|
||||
@ -709,7 +755,8 @@ export default {
|
||||
<span v-if="!total" class="ms-2">Mailpit</span>
|
||||
</a>
|
||||
<div v-if="total" class="ms-md-2 d-flex bg-white border rounded-start flex-fill position-relative">
|
||||
<input type="text" class="form-control border-0" v-model.trim="search" placeholder="Search mailbox">
|
||||
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
|
||||
placeholder="Search mailbox">
|
||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search"
|
||||
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||
</div>
|
||||
@ -720,14 +767,29 @@ export default {
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0" v-if="!message && total">
|
||||
<button v-if="total" class="btn btn-danger float-start d-md-none me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" title="Delete all messages">
|
||||
<button v-if="searching && items.length" class="btn btn-danger float-start d-md-none me-2"
|
||||
data-bs-toggle="modal" data-bs-target="#DeleteSearchModal" :disabled="!items" title="Delete results">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
<button v-else class="btn btn-danger float-start d-md-none me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!total || searching" title="Delete all messages">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
|
||||
<!-- TODO
|
||||
<button v-if="unread" class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" title="Mark all read">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
</button> -->
|
||||
|
||||
<button v-if="searching && items.length" class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkSearchReadModal" :disabled="!unreadInSearch"
|
||||
:title="'Mark ' + formatNumber(unreadInSearch) + ' read'">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
</button>
|
||||
<button v-else class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!unread || searching">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
</button>
|
||||
|
||||
<select v-model="limit" v-on:change="loadMessages" class="form-select form-select-sm d-inline w-auto me-2"
|
||||
@ -777,17 +839,28 @@ export default {
|
||||
</a>
|
||||
|
||||
<template v-if="!message && !selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
<button v-if="searching && items.length" class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal" data-bs-target="#MarkSearchReadModal" :disabled="!unreadInSearch">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark {{ formatNumber(unreadInSearch) }} read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!unread || searching">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
<button v-if="searching && items.length" class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal" data-bs-target="#DeleteSearchModal" :disabled="!items">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete {{ formatNumber(items.length) }} results
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!total || searching">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal"
|
||||
v-if="isConnected && notificationsSupported && !notificationsEnabled">
|
||||
@ -808,7 +881,7 @@ export default {
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
@ -820,7 +893,7 @@ export default {
|
||||
<template v-if="!selected.length && tags.length && !message">
|
||||
<h6 class="mt-4 text-muted"><small>Tags</small></h6>
|
||||
<div class="list-group mt-2 mb-5">
|
||||
<button class="list-group-item list-group-item-action" v-for="tag in tags"
|
||||
<button class="list-group-item list-group-item-action small" v-for="tag in tags"
|
||||
v-on:click="tagSearch($event, tag)" :class="inSearch(tag) ? 'active' : ''">
|
||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||
<i class="bi bi-tag" v-else></i>
|
||||
@ -929,6 +1002,29 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="DeleteSearchModal" tabindex="-1" aria-labelledby="DeleteSearchModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteSearchModalLabel">
|
||||
Delete {{ formatNumber(items.length) }} search result<span v-if="items.length > 1">s</span>?
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(items.length) }} message<span v-if="total > 1">s</span>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteSearch">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
@ -949,6 +1045,30 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="MarkSearchReadModal" tabindex="-1" aria-labelledby="MarkSearchReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkSearchReadModalLabel">
|
||||
Mark {{ formatNumber(unreadInSearch) }} search result<span v-if="unreadInSearch > 1">s</span> as
|
||||
read?
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(unreadInSearch) }} message<span v-if="unread > 1">s</span> as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markSearchRead">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
|
||||
aria-hidden="true">
|
||||
|
@ -117,10 +117,6 @@ type DBMailSummary struct {
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
// Subject string
|
||||
// Size int
|
||||
// Inline int
|
||||
// Attachments int
|
||||
}
|
||||
|
||||
// InitDB will initialise the database
|
||||
@ -255,7 +251,16 @@ func Store(body []byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tagData := findTags(&body)
|
||||
// extract tags from body matches based on --tag
|
||||
tagStr := findTagsInRawMessage(&body)
|
||||
|
||||
// extract tags from X-Tags header
|
||||
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
|
||||
if headerTags != "" {
|
||||
tagStr += "," + headerTags
|
||||
}
|
||||
|
||||
tagData := uniqueTagsFromString(tagStr)
|
||||
|
||||
tagJSON, err := json.Marshal(tagData)
|
||||
if err != nil {
|
||||
@ -376,7 +381,7 @@ func List(start, limit int) ([]MessageSummary, error) {
|
||||
}
|
||||
|
||||
// Search will search a mailbox for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as:
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func Search(search string, start, limit int) ([]MessageSummary, error) {
|
||||
@ -886,7 +891,7 @@ func IsUnread(id string) bool {
|
||||
return unread == 1
|
||||
}
|
||||
|
||||
// MessageIDExists blaah
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
||||
|
@ -3,23 +3,21 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SetTags will set the tags for a given database 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) {
|
||||
t = tools.CleanTag(t)
|
||||
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
|
||||
applyTags = append(applyTags, t)
|
||||
}
|
||||
}
|
||||
@ -42,23 +40,22 @@ func SetTags(id string, tags []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Used to auto-apply tags to new messages
|
||||
func findTags(message *[]byte) []string {
|
||||
tags := []string{}
|
||||
// Find tags set via --tags in raw message.
|
||||
// Returns a comma-separated string.
|
||||
func findTagsInRawMessage(message *[]byte) string {
|
||||
tagStr := ""
|
||||
if len(config.SMTPTags) == 0 {
|
||||
return tags
|
||||
return tagStr
|
||||
}
|
||||
|
||||
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)
|
||||
if strings.Contains(str, t.Match) {
|
||||
tagStr += "," + t.Tag
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return tags
|
||||
return tagStr
|
||||
}
|
||||
|
||||
// Get message tags from the database for a given database ID
|
||||
@ -84,3 +81,31 @@ func getMessageTags(id string) []string {
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
|
||||
func uniqueTagsFromString(s string) []string {
|
||||
tags := []string{}
|
||||
|
||||
if s == "" {
|
||||
return tags
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ",")
|
||||
for _, p := range parts {
|
||||
w := tools.CleanTag(p)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if config.ValidTagRegexp.MatchString(w) {
|
||||
if !inArray(w, tags) {
|
||||
tags = append(tags, w)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Debugf("[db] ignoring invalid tag: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return tags
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ func dbCron() {
|
||||
time.Sleep(60 * time.Second)
|
||||
start := time.Now()
|
||||
|
||||
// check if database contains deleted data and has not beein in use
|
||||
// check if database contains deleted data and has not been in use
|
||||
// for 5 minutes, if so VACUUM
|
||||
currentTime := time.Now()
|
||||
diff := currentTime.Sub(dbLastAction)
|
||||
@ -167,6 +167,7 @@ func isFile(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// InArray tests if a string in within an array. It is not case sensitive.
|
||||
func inArray(k string, arr []string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, v := range arr {
|
||||
|
@ -1,4 +1,4 @@
|
||||
// Package tools provides various methods for variouws things
|
||||
// Package tools provides various methods for various things
|
||||
package tools
|
||||
|
||||
import (
|
||||
@ -23,7 +23,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
|
||||
for _, hdr := range headers {
|
||||
// case-insentitive
|
||||
// case-insensitive
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
|
||||
|
||||
// header := []byte(hdr + ":")
|
||||
|
25
utils/tools/tags.go
Normal file
25
utils/tools/tags.go
Normal file
@ -0,0 +1,25 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// Invalid tag characters regex
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
|
||||
|
||||
// Regex to catch multiple spaces
|
||||
multiSpaceRe = regexp.MustCompile(`(\s+)`)
|
||||
)
|
||||
|
||||
// CleanTag returns a clean tag, removing whitespace and invalid characters
|
||||
func CleanTag(s string) string {
|
||||
s = strings.TrimSpace(
|
||||
multiSpaceRe.ReplaceAllString(
|
||||
tagsInvalidChars.ReplaceAllString(s, " "),
|
||||
" ",
|
||||
),
|
||||
)
|
||||
return s
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user