mirror of
https://github.com/axllent/mailpit.git
synced 2024-12-26 22:56:43 +02:00
parent
57cfb2611c
commit
6bc02fd4d4
@ -139,6 +139,7 @@ func init() {
|
|||||||
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
|
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.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().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.QuietLogging, "quiet", "q", false, "Quiet logging (errors only)")
|
||||||
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
|
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mattn/go-shellwords"
|
||||||
"github.com/tg123/go-htpasswd"
|
"github.com/tg123/go-htpasswd"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -61,6 +62,15 @@ var (
|
|||||||
// SMTPAuth used for euthentication
|
// SMTPAuth used for euthentication
|
||||||
SMTPAuth *htpasswd.File
|
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 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';"
|
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"
|
RepoBinaryName = "mailpit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Tag struct
|
||||||
|
type Tag struct {
|
||||||
|
Tag string
|
||||||
|
Match string
|
||||||
|
}
|
||||||
|
|
||||||
// VerifyConfig wil do some basic checking
|
// VerifyConfig wil do some basic checking
|
||||||
func VerifyConfig() error {
|
func VerifyConfig() error {
|
||||||
if DataFile != "" && isDir(DataFile) {
|
if DataFile != "" && isDir(DataFile) {
|
||||||
@ -151,6 +167,35 @@ func VerifyConfig() error {
|
|||||||
s := path.Join("/", Webroot, "/")
|
s := path.Join("/", Webroot, "/")
|
||||||
Webroot = s
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,4 +8,5 @@ The API is split into three main parts:
|
|||||||
|
|
||||||
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
|
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
|
||||||
- [Message](Message.md) - Return message data & attachments
|
- [Message](Message.md) - Return message data & attachments
|
||||||
|
- [Tags](Tags.md) - Set message tags
|
||||||
- [Search](Search.md) - Searching messages
|
- [Search](Search.md) - Searching messages
|
||||||
|
27
docs/apiv1/Tags.md
Normal file
27
docs/apiv1/Tags.md
Normal file
@ -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": ["<ID>","<ID>"...],
|
||||||
|
"tags": ["<tag>","<tag>"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Status** : `200`
|
Binary file not shown.
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 83 KiB |
11
package-lock.json
generated
11
package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"bootstrap": "^5.2.0",
|
"bootstrap": "^5.2.0",
|
||||||
"bootstrap-icons": "^1.9.1",
|
"bootstrap-icons": "^1.9.1",
|
||||||
|
"bootstrap5-tags": "^1.4.41",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"tinycon": "^0.6.8",
|
"tinycon": "^0.6.8",
|
||||||
@ -237,6 +238,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.9.1.tgz",
|
||||||
"integrity": "sha512-d4ZkO30MIkAhQ2nNRJqKXJVEQorALGbLWTuRxyCTJF96lRIV6imcgMehWGJUiJMJhglN0o2tqLIeDnMdiQEE9g=="
|
"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": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.9.1.tgz",
|
||||||
"integrity": "sha512-d4ZkO30MIkAhQ2nNRJqKXJVEQorALGbLWTuRxyCTJF96lRIV6imcgMehWGJUiJMJhglN0o2tqLIeDnMdiQEE9g=="
|
"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": {
|
"braces": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"bootstrap": "^5.2.0",
|
"bootstrap": "^5.2.0",
|
||||||
"bootstrap-icons": "^1.9.1",
|
"bootstrap-icons": "^1.9.1",
|
||||||
|
"bootstrap5-tags": "^1.4.41",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"tinycon": "^0.6.8",
|
"tinycon": "^0.6.8",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package apiv1
|
package apiv1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -32,6 +33,7 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
res.Count = len(messages)
|
res.Count = len(messages)
|
||||||
res.Total = stats.Total
|
res.Total = stats.Total
|
||||||
res.Unread = stats.Unread
|
res.Unread = stats.Unread
|
||||||
|
res.Tags = stats.Tags
|
||||||
|
|
||||||
bytes, _ := json.Marshal(res)
|
bytes, _ := json.Marshal(res)
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
@ -63,6 +65,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
res.Count = len(messages)
|
res.Count = len(messages)
|
||||||
res.Total = stats.Total
|
res.Total = stats.Total
|
||||||
res.Unread = stats.Unread
|
res.Unread = stats.Unread
|
||||||
|
res.Tags = stats.Tags
|
||||||
|
|
||||||
bytes, _ := json.Marshal(res)
|
bytes, _ := json.Marshal(res)
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
@ -234,6 +237,36 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = 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
|
// FourOFour returns a basic 404 message
|
||||||
func fourOFour(w http.ResponseWriter) {
|
func fourOFour(w http.ResponseWriter) {
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
@ -14,6 +14,7 @@ type MessagesSummary struct {
|
|||||||
Unread int `json:"unread"`
|
Unread int `json:"unread"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Start int `json:"start"`
|
Start int `json:"start"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
Messages []MessageSummary `json:"messages"`
|
Messages []MessageSummary `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.GetMessages)).Methods("GET")
|
||||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
|
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/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/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}", 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}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import commonMixins from './mixins.js';
|
import commonMixins from './mixins.js';
|
||||||
import Message from './templates/Message.vue';
|
import Message from './templates/Message.vue';
|
||||||
|
import MessageSummary from './templates/MessageSummary.vue';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import Tinycon from 'tinycon';
|
import Tinycon from 'tinycon';
|
||||||
|
|
||||||
@ -8,7 +9,8 @@ export default {
|
|||||||
mixins: [commonMixins],
|
mixins: [commonMixins],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Message
|
Message,
|
||||||
|
MessageSummary
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
@ -20,6 +22,8 @@ export default {
|
|||||||
unread: 0,
|
unread: 0,
|
||||||
start: 0,
|
start: 0,
|
||||||
count: 0,
|
count: 0,
|
||||||
|
tags: [],
|
||||||
|
existingTags: [], // to pass onto components
|
||||||
search: "",
|
search: "",
|
||||||
searching: false,
|
searching: false,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@ -99,7 +103,7 @@ export default {
|
|||||||
|
|
||||||
let self = this;
|
let self = this;
|
||||||
let params = {};
|
let params = {};
|
||||||
this.selected = [];
|
self.selected = [];
|
||||||
|
|
||||||
let uri = 'api/v1/messages';
|
let uri = 'api/v1/messages';
|
||||||
if (self.search) {
|
if (self.search) {
|
||||||
@ -123,7 +127,10 @@ export default {
|
|||||||
self.count = response.data.count;
|
self.count = response.data.count;
|
||||||
self.start = response.data.start;
|
self.start = response.data.start;
|
||||||
self.items = response.data.messages;
|
self.items = response.data.messages;
|
||||||
|
self.tags = response.data.tags;
|
||||||
|
if (!self.existingTags.length) {
|
||||||
|
self.existingTags = JSON.parse(JSON.stringify(self.tags));
|
||||||
|
}
|
||||||
// if pagination > 0 && results == 0 reload first page (prune)
|
// if pagination > 0 && results == 0 reload first page (prune)
|
||||||
if (response.data.count == 0 && response.data.start > 0) {
|
if (response.data.count == 0 && response.data.start > 0) {
|
||||||
self.start = 0;
|
self.start = 0;
|
||||||
@ -146,6 +153,16 @@ export default {
|
|||||||
this.loadMessages();
|
this.loadMessages();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
tagSearch: function (e, tag) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (tag.match(/ /)) {
|
||||||
|
tag = '"' + tag + '"';
|
||||||
|
}
|
||||||
|
this.search = 'tag:' + tag;
|
||||||
|
window.location.hash = "";
|
||||||
|
this.loadMessages();
|
||||||
|
},
|
||||||
|
|
||||||
resetSearch: function (e) {
|
resetSearch: function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.search = '';
|
this.search = '';
|
||||||
@ -176,9 +193,11 @@ export default {
|
|||||||
openMessage: function (id) {
|
openMessage: function (id) {
|
||||||
let self = this;
|
let self = this;
|
||||||
self.selected = [];
|
self.selected = [];
|
||||||
|
self.existingTags = JSON.parse(JSON.stringify(self.tags));
|
||||||
|
|
||||||
let uri = 'api/v1/message/' + self.currentPath
|
let uri = 'api/v1/message/' + self.currentPath
|
||||||
self.get(uri, false, function (response) {
|
self.get(uri, false, function (response) {
|
||||||
|
|
||||||
for (let i in self.items) {
|
for (let i in self.items) {
|
||||||
if (self.items[i].ID == self.currentPath) {
|
if (self.items[i].ID == self.currentPath) {
|
||||||
if (!self.items[i].Read) {
|
if (!self.items[i].Read) {
|
||||||
@ -375,6 +394,14 @@ export default {
|
|||||||
}
|
}
|
||||||
self.total++;
|
self.total++;
|
||||||
self.unread++;
|
self.unread++;
|
||||||
|
|
||||||
|
for (let i in response.Data.Tags) {
|
||||||
|
if (self.tags.indexOf(response.Data.Tags[i]) < 0) {
|
||||||
|
self.tags.push(response.Data.Tags[i]);
|
||||||
|
self.tags.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]';
|
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]';
|
||||||
self.browserNotify("New mail from: " + from, response.Data.Subject);
|
self.browserNotify("New mail from: " + from, response.Data.Subject);
|
||||||
} else if (response.Type == "prune") {
|
} else if (response.Type == "prune") {
|
||||||
@ -500,6 +527,15 @@ export default {
|
|||||||
return this.selected.indexOf(id) != -1;
|
return this.selected.indexOf(id) != -1;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
inSearch: function (tag) {
|
||||||
|
tag = tag.toLowerCase();
|
||||||
|
if (tag.match(/ /)) {
|
||||||
|
tag = '"' + tag + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.search.toLowerCase().indexOf('tag:' + tag) > -1;
|
||||||
|
},
|
||||||
|
|
||||||
loadInfo: function (e) {
|
loadInfo: function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let self = this;
|
let self = this;
|
||||||
@ -522,7 +558,8 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col col-md-9 col-lg-10" v-if="message">
|
<div class="col col-md-9 col-lg-10" v-if="message">
|
||||||
<a class="btn btn-outline-light me-4 px-3" href="#" v-on:click="message = false" title="Return to messages">
|
<a class="btn btn-outline-light me-4 px-3 d-md-none" href="#" v-on:click="message = false"
|
||||||
|
title="Return to messages">
|
||||||
<i class="bi bi-arrow-return-left"></i>
|
<i class="bi bi-arrow-return-left"></i>
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-outline-light me-2" title="Mark unread" v-on:click="markUnread">
|
<button class="btn btn-outline-light me-2" title="Mark unread" v-on:click="markUnread">
|
||||||
@ -552,7 +589,7 @@ export default {
|
|||||||
<img src="mailpit.svg" alt="Mailpit">
|
<img src="mailpit.svg" alt="Mailpit">
|
||||||
<span v-if="!total" class="ms-2">Mailpit</span>
|
<span v-if="!total" class="ms-2">Mailpit</span>
|
||||||
</a>
|
</a>
|
||||||
<div v-if="total" class="d-flex bg-white border rounded-start flex-fill position-relative">
|
<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"
|
<input type="text" class="form-control border-0" v-model.trim="search"
|
||||||
placeholder="Search mailbox">
|
placeholder="Search mailbox">
|
||||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search"
|
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search"
|
||||||
@ -586,9 +623,8 @@ export default {
|
|||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<small>
|
<small>
|
||||||
{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small> {{
|
{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small>
|
||||||
formatNumber(total)
|
{{ formatNumber(total) }}
|
||||||
}}
|
|
||||||
</small>
|
</small>
|
||||||
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||||
v-if="!searching" :title="'View previous ' + limit + ' messages'">
|
v-if="!searching" :title="'View previous ' + limit + ' messages'">
|
||||||
@ -602,77 +638,85 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row flex-fill" style="min-height:0">
|
<div class="row flex-fill" style="min-height:0">
|
||||||
<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative" style="overflow-y: auto;">
|
<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative"
|
||||||
<ul class="list-unstyled mt-3 mb-5">
|
style="overflow-y: auto; overflow-x: hidden;">
|
||||||
<li v-if="isConnected" title="Messages will auto-load" class="mb-3 text-muted">
|
|
||||||
<i class="bi bi-power text-success"></i>
|
<div class="list-group my-2">
|
||||||
Connected
|
<a href="#" v-on:click="message ? message = false : reloadMessages()"
|
||||||
</li>
|
class="list-group-item list-group-item-action" :class="!searching && !message ? 'active' : ''">
|
||||||
<li v-else title="You need to manually refresh your mailbox" class="mb-3">
|
<template v-if="isConnected">
|
||||||
<i class="bi bi-power text-danger"></i>
|
<i class="bi bi-envelope-fill me-1" v-if="!searching && !message"></i>
|
||||||
Disconnected
|
<i class="bi bi-arrow-return-left" v-else></i>
|
||||||
</li>
|
</template>
|
||||||
<li class="mb-5">
|
|
||||||
<a class="position-relative ps-0" href="#" v-on:click="reloadMessages">
|
|
||||||
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
|
|
||||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||||
Inbox
|
<span v-if="message" class="ms-1">Return</span>
|
||||||
<span class="badge rounded-pill text-bg-primary ms-1" title="Unread messages" v-if="unread">
|
<span v-else class="ms-1">Inbox</span>
|
||||||
|
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages">
|
||||||
{{ formatNumber(unread) }}
|
{{ formatNumber(unread) }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
|
||||||
<li class="my-3" v-if="!message && unread && !selected.length">
|
<template v-if="!message && !selected.length">
|
||||||
<a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal">
|
<button 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"></i>
|
<i class="bi bi-eye-fill"></i>
|
||||||
Mark all read
|
Mark all read
|
||||||
</a>
|
</button>
|
||||||
</li>
|
|
||||||
<li class="my-3" v-if="!message && total && !selected.length">
|
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||||
<a href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal">
|
data-bs-target="#DeleteAllModal" :disabled="!total || searching">
|
||||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||||
Delete all
|
Delete all
|
||||||
</a>
|
</button>
|
||||||
</li>
|
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#EnableNotificationsModal"
|
||||||
<li class="my-3" v-if="selected.length > 0">
|
v-if="isConnected && notificationsSupported && !notificationsEnabled">
|
||||||
<b class="me-2">Selected {{ selected.length }}</b>
|
|
||||||
<button class="btn btn-sm text-muted" v-on:click="selected = []" title="Unselect messages"><i
|
|
||||||
class="bi bi-x-circle"></i></button>
|
|
||||||
</li>
|
|
||||||
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasUnread()">
|
|
||||||
<a href="#" v-on:click="markSelectedRead">
|
|
||||||
<i class="bi bi-eye-fill"></i>
|
|
||||||
Mark read
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasRead()">
|
|
||||||
<a href="#" v-on:click="markSelectedUnread">
|
|
||||||
<i class="bi bi-eye-slash"></i>
|
|
||||||
Mark unread
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="my-3 ms-2" v-if="total && selected.length > 0">
|
|
||||||
<a href="#" v-on:click="deleteMessages">
|
|
||||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
|
||||||
Delete
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="my-3" v-if="notificationsSupported && !notificationsEnabled">
|
|
||||||
<a href="#" data-bs-toggle="modal" data-bs-target="#EnableNotificationsModal"
|
|
||||||
title="Enable browser notifications">
|
|
||||||
<i class="bi bi-bell"></i>
|
<i class="bi bi-bell"></i>
|
||||||
Enable alerts
|
Enable alerts
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</template>
|
||||||
<li class="mt-5 position-fixed bottom-0 bg-white py-2 text-muted">
|
<template v-if="!message && selected.length">
|
||||||
|
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
|
||||||
|
v-on:click="markSelectedRead">
|
||||||
|
<i class="bi bi-eye-fill"></i>
|
||||||
|
Mark selected read
|
||||||
|
</button>
|
||||||
|
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
|
||||||
|
v-on:click="markSelectedUnread">
|
||||||
|
<i class="bi bi-eye-slash"></i>
|
||||||
|
Mark selected unread
|
||||||
|
</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 selected
|
||||||
|
</button>
|
||||||
|
<button class="list-group-item list-group-item-action" v-on:click="selected = []">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>
|
||||||
|
Cancel selection
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MessageSummary v-if="message" :message="message"></MessageSummary>
|
||||||
|
|
||||||
|
<div class="position-fixed bottom-0 bg-white py-2 text-muted w-100">
|
||||||
<a href="#" class="text-muted" v-on:click="loadInfo">
|
<a href="#" class="text-muted" v-on:click="loadInfo">
|
||||||
<i class="bi bi-info-circle-fill"></i>
|
<i class="bi bi-info-circle-fill"></i>
|
||||||
About
|
About
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-10 col-md-9 mh-100 pe-0">
|
<div class="col-lg-10 col-md-9 mh-100 pe-0">
|
||||||
@ -706,6 +750,10 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6 mt-2 mt-lg-0">
|
<div class="col-lg-6 mt-2 mt-lg-0">
|
||||||
|
<span class="badge text-bg-secondary me-1" v-for="t in message.Tags"
|
||||||
|
:title="'Filter messages tagged with ' + t" v-on:click="tagSearch($event, t)">
|
||||||
|
{{ t }}
|
||||||
|
</span>
|
||||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||||
@ -727,7 +775,8 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Message v-if="message" :message="message"></Message>
|
<Message v-if="message" :message="message" :existingTags="existingTags" @load-messages="loadMessages">
|
||||||
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
<div id="loading" v-if="loading">
|
<div id="loading" v-if="loading">
|
||||||
<div class="d-flex justify-content-center align-items-center h-100">
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
@ -797,8 +846,8 @@ export default {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"
|
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||||
v-on:click="requestNotifications">Enable notifications</button>
|
v-on:click="requestNotifications">Enable notifications</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
$link-decoration: none;
|
$link-decoration: none;
|
||||||
$primary: #2c3e50;
|
$primary: #2c3e50;
|
||||||
|
$list-group-disabled-color: #adb5bd;
|
||||||
|
@ -85,7 +85,7 @@
|
|||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:first-child {
|
.list-group-item.message:first-child {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,6 +149,23 @@ body.blur {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .tag.active {
|
||||||
|
// font-weight: bold;
|
||||||
|
// }
|
||||||
|
|
||||||
|
.form-select.tag-selector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.dropdown {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* PrismJS 1.29.0 - modified!
|
/* PrismJS 1.29.0 - modified!
|
||||||
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
|
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
|
||||||
code[class*="language-"],
|
code[class*="language-"],
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Modal } from 'bootstrap';
|
import { Modal } from 'bootstrap';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
|
||||||
// FakeModal is used to return a fake Bootstrap modal
|
// FakeModal is used to return a fake Bootstrap modal
|
||||||
@ -26,6 +27,10 @@ const commonMixins = {
|
|||||||
return new Intl.NumberFormat().format(nr);
|
return new Intl.NumberFormat().format(nr);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
messageDate: function (d) {
|
||||||
|
return moment(d).format('ddd, D MMM YYYY, h:mm a');
|
||||||
|
},
|
||||||
|
|
||||||
// Ajax error message
|
// Ajax error message
|
||||||
handleError: function (error) {
|
handleError: function (error) {
|
||||||
// handle error
|
// handle error
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import commonMixins from '../mixins.js';
|
import commonMixins from '../mixins.js';
|
||||||
import moment from 'moment';
|
|
||||||
import Prism from "prismjs";
|
import Prism from "prismjs";
|
||||||
import Attachments from './Attachments.vue';
|
import Attachments from './Attachments.vue';
|
||||||
|
import MessageTags from './MessageTags.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
message: Object
|
message: Object,
|
||||||
|
existingTags: Array
|
||||||
},
|
},
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Attachments
|
Attachments,
|
||||||
|
MessageTags
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [commonMixins],
|
mixins: [commonMixins],
|
||||||
@ -20,6 +22,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
srcURI: false,
|
srcURI: false,
|
||||||
iframes: [], // for resizing
|
iframes: [], // for resizing
|
||||||
|
tagComponent: false, // to force rerendering of component
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -27,10 +30,12 @@ export default {
|
|||||||
message: {
|
message: {
|
||||||
handler(newQuestion) {
|
handler(newQuestion) {
|
||||||
let self = this;
|
let self = this;
|
||||||
// delay 100ms to select first tab and add HTML highlighting (prev/next)
|
self.tagComponent = false;
|
||||||
window.setTimeout(function () {
|
// delay to select first tab and add HTML highlighting (prev/next)
|
||||||
|
self.$nextTick(function () {
|
||||||
self.renderUI();
|
self.renderUI();
|
||||||
}, 100)
|
self.tagComponent = true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
// force eager callback execution
|
// force eager callback execution
|
||||||
immediate: true
|
immediate: true
|
||||||
@ -39,12 +44,14 @@ export default {
|
|||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
let self = this;
|
let self = this;
|
||||||
|
self.tagComponent = false;
|
||||||
window.addEventListener("resize", self.resizeIframes);
|
window.addEventListener("resize", self.resizeIframes);
|
||||||
self.renderUI();
|
self.renderUI();
|
||||||
var tabEl = document.getElementById('nav-raw-tab');
|
var tabEl = document.getElementById('nav-raw-tab');
|
||||||
tabEl.addEventListener('shown.bs.tab', function (event) {
|
tabEl.addEventListener('shown.bs.tab', function (event) {
|
||||||
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw';
|
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw';
|
||||||
});
|
});
|
||||||
|
self.tagComponent = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
unmounted: function () {
|
unmounted: function () {
|
||||||
@ -59,9 +66,9 @@ export default {
|
|||||||
document.activeElement.blur(); // blur focus
|
document.activeElement.blur(); // blur focus
|
||||||
document.getElementById('message-view').scrollTop = 0;
|
document.getElementById('message-view').scrollTop = 0;
|
||||||
|
|
||||||
window.setTimeout(function () {
|
// delay until vue has rendered
|
||||||
|
self.$nextTick(function () {
|
||||||
let p = document.getElementById('preview-html');
|
let p = document.getElementById('preview-html');
|
||||||
|
|
||||||
if (p) {
|
if (p) {
|
||||||
// make links open in new window
|
// make links open in new window
|
||||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
|
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
|
||||||
@ -75,7 +82,7 @@ export default {
|
|||||||
}
|
}
|
||||||
self.resizeIframes();
|
self.resizeIframes();
|
||||||
}
|
}
|
||||||
}, 200);
|
});
|
||||||
|
|
||||||
// html highlighting
|
// html highlighting
|
||||||
window.Prism = window.Prism || {};
|
window.Prism = window.Prism || {};
|
||||||
@ -98,10 +105,6 @@ export default {
|
|||||||
if (s) {
|
if (s) {
|
||||||
s.style.height = s.contentWindow.document.body.scrollHeight + 50 + 'px';
|
s.style.height = s.contentWindow.document.body.scrollHeight + 50 + 'px';
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
messageDate: function (d) {
|
|
||||||
return moment(d).format('ddd, D MMM YYYY, h:mm a');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,17 +158,21 @@ export default {
|
|||||||
<th class="small">Subject</th>
|
<th class="small">Subject</th>
|
||||||
<td><strong>{{ message.Subject }}</strong></td>
|
<td><strong>{{ message.Subject }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="d-md-none">
|
<tr class="d-md-none small">
|
||||||
<th class="small">Date</th>
|
<th class="small">Date</th>
|
||||||
<td>{{ messageDate(message.Date) }}</td>
|
<td>{{ messageDate(message.Date) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<MessageTags :message="message" :existingTags="existingTags"
|
||||||
|
@load-messages="$emit('loadMessages')" v-if="tagComponent">
|
||||||
|
</MessageTags>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-auto text-md-end mt-md-3">
|
<div class="col-md-auto text-md-end mt-md-3">
|
||||||
<p class="text-muted small d-none d-md-block"><small>{{ messageDate(message.Date) }}</small></p>
|
<!-- <p class="text-muted small d-none d-md-block mb-2"><small>{{ messageDate(message.Date) }}</small></p>
|
||||||
<div class="dropdown mt-2" v-if="allAttachments(message)">
|
<p class="text-muted small d-none d-md-block"><small>Size: {{ getFileSize(message.Size) }}</small></p> -->
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
<div class="dropdown mt-2 mt-md-0" v-if="allAttachments(message)">
|
||||||
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||||
aria-expanded="false">
|
aria-expanded="false">
|
||||||
Attachment<span v-if="allAttachments(message).length > 1">s</span>
|
Attachment<span v-if="allAttachments(message).length > 1">s</span>
|
||||||
({{ allAttachments(message).length }})
|
({{ allAttachments(message).length }})
|
||||||
|
28
server/ui-src/templates/MessageSummary.vue
Normal file
28
server/ui-src/templates/MessageSummary.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<script>
|
||||||
|
import commonMixins from '../mixins.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
message: Object
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [commonMixins]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body text-muted small">
|
||||||
|
<p class="card-text">
|
||||||
|
<b>Message date:</b><br>
|
||||||
|
<small>{{ messageDate(message.Date) }}</small>
|
||||||
|
</p>
|
||||||
|
<p class="card-text">
|
||||||
|
<b>Size:</b> {{ getFileSize(message.Size) }}
|
||||||
|
</p>
|
||||||
|
<p class="card-text" v-if="allAttachments(message).length">
|
||||||
|
<b>Attachments:</b> {{ allAttachments(message).length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
72
server/ui-src/templates/MessageTags.vue
Normal file
72
server/ui-src/templates/MessageTags.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
<script>
|
||||||
|
import commonMixins from '../mixins.js';
|
||||||
|
import Tags from "bootstrap5-tags";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
message: Object,
|
||||||
|
existingTags: Array
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
messageTags: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
let self = this;
|
||||||
|
self.loaded = false;
|
||||||
|
self.messageTags = self.message.Tags;
|
||||||
|
// delay until vue has rendered
|
||||||
|
self.$nextTick(function () {
|
||||||
|
Tags.init("select[multiple]");
|
||||||
|
self.$nextTick(function () {
|
||||||
|
self.loaded = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
messageTags() {
|
||||||
|
if (this.loaded) {
|
||||||
|
this.saveTags();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
saveTags: function () {
|
||||||
|
let self = this;
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
ids: [this.message.ID],
|
||||||
|
tags: this.messageTags
|
||||||
|
}
|
||||||
|
|
||||||
|
self.put('api/v1/tags', data, function (response) {
|
||||||
|
self.scrollInPlace = true;
|
||||||
|
self.$emit('loadMessages');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr class="small">
|
||||||
|
<th>Tags</th>
|
||||||
|
<td>
|
||||||
|
<select class="form-select small tag-selector" v-model="messageTags" multiple data-allow-new="true"
|
||||||
|
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
|
||||||
|
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_]){3,}$" data-separator="|,|">
|
||||||
|
<option value="">Type a tag...</option><!-- you need at least one option with the placeholder -->
|
||||||
|
<option v-for="t in existingTags" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="invalid-feedback">Please select a valid tag.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
@ -14,6 +14,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@ -65,6 +66,12 @@ var (
|
|||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
|
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
|
// generate unique ID
|
||||||
id := uuid.NewV4().String()
|
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
|
// begin a transaction to ensure both the message
|
||||||
// and data are stored successfully
|
// and data are stored successfully
|
||||||
@ -213,8 +230,8 @@ func Store(body []byte) (string, error) {
|
|||||||
// roll back if it fails
|
// roll back if it fails
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// insert summary
|
// insert mail summary data
|
||||||
_, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Read) values(?,?,?, 0)", id, string(b), searchText)
|
_, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Tags, Read) values(?,?,?,?,0)", id, string(summaryJSON), searchText, string(tagJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -231,10 +248,12 @@ func Store(body []byte) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := &MessageSummary{}
|
c := &MessageSummary{}
|
||||||
if err := json.Unmarshal(b, c); err != nil {
|
if err := json.Unmarshal(summaryJSON, c); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Tags = tagData
|
||||||
|
|
||||||
c.ID = id
|
c.ID = id
|
||||||
|
|
||||||
websockets.Broadcast("new", c)
|
websockets.Broadcast("new", c)
|
||||||
@ -250,7 +269,7 @@ func List(start, limit int) ([]MessageSummary, error) {
|
|||||||
results := []MessageSummary{}
|
results := []MessageSummary{}
|
||||||
|
|
||||||
q := sqlf.From("mailbox").
|
q := sqlf.From("mailbox").
|
||||||
Select(`ID, Data, Read`).
|
Select(`ID, Data, Tags, Read`).
|
||||||
OrderBy("Sort DESC").
|
OrderBy("Sort DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Offset(start)
|
Offset(start)
|
||||||
@ -258,16 +277,21 @@ func List(start, limit int) ([]MessageSummary, error) {
|
|||||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||||
var id string
|
var id string
|
||||||
var summary string
|
var summary string
|
||||||
|
var tags string
|
||||||
var read int
|
var read int
|
||||||
em := MessageSummary{}
|
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)
|
logger.Log().Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(summary), &em)
|
if err := json.Unmarshal([]byte(summary), &em); err != nil {
|
||||||
if err != nil {
|
logger.Log().Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||||
logger.Log().Error(err)
|
logger.Log().Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -313,17 +337,22 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
|
|||||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||||
var id string
|
var id string
|
||||||
var summary string
|
var summary string
|
||||||
|
var tags string
|
||||||
var read int
|
var read int
|
||||||
var ignore string
|
var ignore string
|
||||||
em := MessageSummary{}
|
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)
|
logger.Log().Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(summary), &em)
|
if err := json.Unmarshal([]byte(summary), &em); err != nil {
|
||||||
if err != nil {
|
logger.Log().Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||||
logger.Log().Error(err)
|
logger.Log().Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -378,6 +407,7 @@ func GetMessage(id string) (*Message, error) {
|
|||||||
Cc: addressToSlice(env, "Cc"),
|
Cc: addressToSlice(env, "Cc"),
|
||||||
Bcc: addressToSlice(env, "Bcc"),
|
Bcc: addressToSlice(env, "Bcc"),
|
||||||
Subject: env.GetHeader("Subject"),
|
Subject: env.GetHeader("Subject"),
|
||||||
|
Tags: getMessageTags(id),
|
||||||
Size: len(raw),
|
Size: len(raw),
|
||||||
Text: env.Text,
|
Text: env.Text,
|
||||||
}
|
}
|
||||||
@ -658,9 +688,42 @@ func StatsGet() MailboxStats {
|
|||||||
|
|
||||||
dbLastAction = time.Now()
|
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{
|
return MailboxStats{
|
||||||
Total: total,
|
Total: total,
|
||||||
Unread: unread,
|
Unread: unread,
|
||||||
|
Tags: tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q := sqlf.From("mailbox").
|
q := sqlf.From("mailbox").
|
||||||
Select(`ID, Data, read,
|
Select(`ID, Data, Tags, Read,
|
||||||
json_extract(Data, '$.To') as ToJSON,
|
json_extract(Data, '$.To') as ToJSON,
|
||||||
json_extract(Data, '$.From') as FromJSON,
|
json_extract(Data, '$.From') as FromJSON,
|
||||||
json_extract(Data, '$.Subject') as Subject,
|
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)+"%")
|
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" {
|
} else if w == "is:read" {
|
||||||
if exclude {
|
if exclude {
|
||||||
q.Where("Read = 0")
|
q.Where("Read = 0")
|
||||||
|
@ -17,6 +17,7 @@ type Message struct {
|
|||||||
Bcc []*mail.Address
|
Bcc []*mail.Address
|
||||||
Subject string
|
Subject string
|
||||||
Date time.Time
|
Date time.Time
|
||||||
|
Tags []string
|
||||||
Text string
|
Text string
|
||||||
HTML string
|
HTML string
|
||||||
Size int
|
Size int
|
||||||
@ -43,6 +44,7 @@ type MessageSummary struct {
|
|||||||
Bcc []*mail.Address
|
Bcc []*mail.Address
|
||||||
Subject string
|
Subject string
|
||||||
Created time.Time
|
Created time.Time
|
||||||
|
Tags []string
|
||||||
Size int
|
Size int
|
||||||
Attachments int
|
Attachments int
|
||||||
}
|
}
|
||||||
@ -51,6 +53,7 @@ type MessageSummary struct {
|
|||||||
type MailboxStats struct {
|
type MailboxStats struct {
|
||||||
Total int
|
Total int
|
||||||
Unread int
|
Unread int
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||||
|
86
storage/tags.go
Normal file
86
storage/tags.go
Normal file
@ -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
|
||||||
|
}
|
@ -20,7 +20,7 @@ import (
|
|||||||
// Return a header field as a []*mail.Address, or "null" is not found/empty
|
// Return a header field as a []*mail.Address, or "null" is not found/empty
|
||||||
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
|
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
|
||||||
data, err := env.AddressList(key)
|
data, err := env.AddressList(key)
|
||||||
if err != nil {
|
if err != nil || data == nil {
|
||||||
return []*mail.Address{}
|
return []*mail.Address{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +162,17 @@ func isFile(path string) bool {
|
|||||||
return true
|
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
|
// escPercentChar replaces `%` with `%%` for SQL searches
|
||||||
func escPercentChar(s string) string {
|
func escPercentChar(s string) string {
|
||||||
return strings.ReplaceAll(s, "%", "%%")
|
return strings.ReplaceAll(s, "%", "%%")
|
||||||
|
Loading…
Reference in New Issue
Block a user