1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-01-22 03:39:59 +02:00

Merge branch 'release/v1.6.14'

This commit is contained in:
Ralph Slooten 2023-06-02 17:22:12 +12:00
commit b6750600cb
13 changed files with 249 additions and 55 deletions

View File

@ -2,6 +2,16 @@
Notable changes to Mailpit will be documented in this file. Notable changes to Mailpit will be documented in this file.
## [v1.6.14]
### Feature
- Add ability to delete or mark search results read
- Set tags via X-Tags message header
### Libs
- Update node modules
## [v1.6.13] ## [v1.6.13]
### Feature ### Feature

View File

@ -11,6 +11,7 @@ import (
"strings" "strings"
"github.com/axllent/mailpit/utils/logger" "github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/mattn/go-shellwords" "github.com/mattn/go-shellwords"
"github.com/tg123/go-htpasswd" "github.com/tg123/go-htpasswd"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -41,7 +42,7 @@ var (
// UIAuthFile for basic authentication // UIAuthFile for basic authentication
UIAuthFile string UIAuthFile string
// UIAuth used for euthentication // UIAuth used for authentication
UIAuth *htpasswd.File UIAuth *htpasswd.File
// Webroot to define the base path for the UI and API // 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 is used to map the CLI args
SMTPCLITags string SMTPCLITags string
// TagRegexp is the allowed tag characters // ValidTagRegexp represents a valid tag
TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`) ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// SMTPTags are expressions to apply tags to new mail // SMTPTags are expressions to apply tags to new mail
SMTPTags []AutoTag SMTPTags []AutoTag
@ -86,7 +87,7 @@ var (
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile // ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false 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! // Use with extreme caution!
SMTPRelayAllIncoming = false SMTPRelayAllIncoming = false
@ -219,8 +220,8 @@ func VerifyConfig() error {
for _, a := range args { for _, a := range args {
t := strings.Split(a, "=") t := strings.Split(a, "=")
if len(t) > 1 { if len(t) > 1 {
tag := strings.TrimSpace(t[0]) tag := tools.CleanTag(t[0])
if !TagRegexp.MatchString(tag) || len(tag) == 0 { if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag) return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
} }
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "="))) match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))

View File

@ -15,6 +15,7 @@ Returns a JSON summary of the message and attachments.
```json ```json
{ {
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de", "ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
"MessageID": "12345.67890@localhost",
"Read": true, "Read": true,
"From": { "From": {
"Name": "John Doe", "Name": "John Doe",
@ -31,6 +32,7 @@ Returns a JSON summary of the message and attachments.
"ReplyTo": [], "ReplyTo": [],
"Subject": "Message subject", "Subject": "Message subject",
"Date": "2016-09-07T16:46:00+13:00", "Date": "2016-09-07T16:46:00+13:00",
"Tags": ["test"],
"Text": "Plain text MIME part of the email", "Text": "Plain text MIME part of the email",
"HTML": "HTML MIME part (if exists)", "HTML": "HTML MIME part (if exists)",
"Size": 79499, "Size": 79499,

View File

@ -31,9 +31,11 @@ List messages in the mailbox. Messages are returned in the order of latest recei
"unread": 500, "unread": 500,
"count": 50, "count": 50,
"start": 0, "start": 0,
"tags": ["test"],
"messages": [ "messages": [
{ {
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f", "ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false, "Read": false,
"From": { "From": {
"Name": "John Doe", "Name": "John Doe",
@ -54,6 +56,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei
"Bcc": [], "Bcc": [],
"Subject": "Message subject", "Subject": "Message subject",
"Created": "2022-10-03T21:35:32.228605299+13:00", "Created": "2022-10-03T21:35:32.228605299+13:00",
"Tags": ["test"],
"Size": 6144, "Size": 6144,
"Attachments": 0 "Attachments": 0
}, },

View File

@ -30,6 +30,7 @@ Matching messages are returned in the order of latest received to oldest.
"messages": [ "messages": [
{ {
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f", "ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false, "Read": false,
"From": { "From": {
"Name": "John Doe", "Name": "John Doe",

34
package-lock.json generated
View File

@ -35,9 +35,9 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.21.9", "version": "7.22.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.9.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.4.tgz",
"integrity": "sha512-q5PNg/Bi1OpGgx5jYlvWZwAorZepEudDMCLtj967aeS7WMont7dUZI46M2XwcIQqvUlMxWfdLFu4S/qSxeUu5g==", "integrity": "sha512-VLLsx06XkEYqBtE5YGPwfSGwfrjnyPP5oiGty3S8pQLFDFLaS8VwWSIxkTXpcvr5zeYLE6+MBNl2npl/YnfofA==",
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
}, },
@ -46,11 +46,11 @@
} }
}, },
"node_modules/@babel/runtime-corejs3": { "node_modules/@babel/runtime-corejs3": {
"version": "7.21.5", "version": "7.22.3",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.21.5.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.22.3.tgz",
"integrity": "sha512-FRqFlFKNazWYykft5zvzuEl1YyTDGsIRrjV9rvxvYkUC7W/ueBng1X68Xd6uRMzAaJ0xMKn08/wem5YS1lpX8w==", "integrity": "sha512-6bdmknScYKmt8I9VjsJuKKGr+TwUb555FTf6tT1P/ANlCjTHCiYLhiQ4X/O7J731w5NOqu8c1aYHEVuOwPz7jA==",
"dependencies": { "dependencies": {
"core-js-pure": "^3.25.1", "core-js-pure": "^3.30.2",
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.13.11"
}, },
"engines": { "engines": {
@ -428,9 +428,9 @@
} }
}, },
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.7", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
@ -1029,9 +1029,9 @@
} }
}, },
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.2.3", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.0.tgz",
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==", "integrity": "sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -1043,7 +1043,7 @@
} }
], ],
"peerDependencies": { "peerDependencies": {
"@popperjs/core": "^2.11.6" "@popperjs/core": "^2.11.7"
} }
}, },
"node_modules/bootstrap-icons": { "node_modules/bootstrap-icons": {
@ -1965,9 +1965,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.23", "version": "8.4.24",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
"integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",

View File

@ -74,6 +74,13 @@ export default {
}, },
canNext: function () { canNext: function () {
return this.total > (this.start + this.count); 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 () { deleteAll: function () {
let self = this; let self = this;
let uri = 'api/v1/messages'; let uri = 'api/v1/messages';
@ -313,6 +338,7 @@ export default {
}); });
}, },
// mark current message as read
markUnread: function () { markUnread: function () {
let self = this; let self = this;
if (!self.message) { if (!self.message) {
@ -326,6 +352,7 @@ export default {
}); });
}, },
// mark all messages in mailbox as read
markAllRead: function () { markAllRead: function () {
let self = this; let self = this;
let uri = 'api/v1/messages' 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 () { markSelectedRead: function () {
let self = this; let self = this;
if (!self.selected.length) { if (!self.selected.length) {
@ -349,6 +394,7 @@ export default {
}); });
}, },
// mark selected messages as unread
markSelectedUnread: function () { markSelectedUnread: function () {
let self = this; let self = this;
if (!self.selected.length) { 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 () { selectedHasUnread: function () {
if (!this.selected.length) { if (!this.selected.length) {
return false; return false;
@ -709,7 +755,8 @@ export default {
<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="ms-md-2 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" 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" <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> v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
</div> </div>
@ -720,14 +767,29 @@ export default {
</form> </form>
</div> </div>
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0" v-if="!message && total"> <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" <button v-if="searching && items.length" class="btn btn-danger float-start d-md-none me-2"
data-bs-target="#DeleteAllModal" title="Delete all messages"> 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> <i class="bi bi-trash-fill"></i>
</button> </button>
<!-- TODO
<button v-if="unread" class="btn btn-light float-start d-md-none" data-bs-toggle="modal" <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"> data-bs-target="#MarkAllReadModal" title="Mark all read">
<i class="bi bi-check2-square"></i> <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> </button>
<select v-model="limit" v-on:change="loadMessages" class="form-select form-select-sm d-inline w-auto me-2" <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> </a>
<template v-if="!message && !selected.length"> <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"> data-bs-target="#MarkAllReadModal" :disabled="!unread || searching">
<i class="bi bi-eye-fill me-1"></i> <i class="bi bi-eye-fill me-1"></i>
Mark all read Mark all read
</button> </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"> 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
</button> </button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal" <button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#EnableNotificationsModal" data-bs-target="#EnableNotificationsModal"
v-if="isConnected && notificationsSupported && !notificationsEnabled"> v-if="isConnected && notificationsSupported && !notificationsEnabled">
@ -808,7 +881,7 @@ export default {
</button> </button>
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages"> <button class="list-group-item list-group-item-action" v-on:click="deleteMessages">
<i class="bi bi-trash-fill me-1 text-danger"></i> <i class="bi bi-trash-fill me-1 text-danger"></i>
Delete Delete selected
</button> </button>
<button class="list-group-item list-group-item-action" v-on:click="selected = []"> <button class="list-group-item list-group-item-action" v-on:click="selected = []">
<i class="bi bi-x-circle me-1"></i> <i class="bi bi-x-circle me-1"></i>
@ -820,7 +893,7 @@ export default {
<template v-if="!selected.length && tags.length && !message"> <template v-if="!selected.length && tags.length && !message">
<h6 class="mt-4 text-muted"><small>Tags</small></h6> <h6 class="mt-4 text-muted"><small>Tags</small></h6>
<div class="list-group mt-2 mb-5"> <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' : ''"> 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-fill" v-if="inSearch(tag)"></i>
<i class="bi bi-tag" v-else></i> <i class="bi bi-tag" v-else></i>
@ -929,6 +1002,29 @@ export default {
</div> </div>
</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 --> <!-- Modal -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true"> <div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@ -949,6 +1045,30 @@ export default {
</div> </div>
</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 --> <!-- Modal -->
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel" <div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
aria-hidden="true"> aria-hidden="true">

View File

@ -4,6 +4,7 @@
// Configuration // Configuration
@import "../../../node_modules/bootstrap/scss/functions"; @import "../../../node_modules/bootstrap/scss/functions";
@import "../../../node_modules/bootstrap/scss/variables"; @import "../../../node_modules/bootstrap/scss/variables";
@import "../../../node_modules/bootstrap/scss/variables-dark";
@import "../../../node_modules/bootstrap/scss/maps"; @import "../../../node_modules/bootstrap/scss/maps";
@import "../../../node_modules/bootstrap/scss/mixins"; @import "../../../node_modules/bootstrap/scss/mixins";
@import "../../../node_modules/bootstrap/scss/utilities"; @import "../../../node_modules/bootstrap/scss/utilities";

View File

@ -117,10 +117,6 @@ type DBMailSummary struct {
To []*mail.Address To []*mail.Address
Cc []*mail.Address Cc []*mail.Address
Bcc []*mail.Address Bcc []*mail.Address
// Subject string
// Size int
// Inline int
// Attachments int
} }
// InitDB will initialise the database // InitDB will initialise the database
@ -255,7 +251,16 @@ func Store(body []byte) (string, error) {
return "", err 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) tagJSON, err := json.Marshal(tagData)
if err != nil { if err != nil {
@ -376,7 +381,7 @@ func List(start, limit int) ([]MessageSummary, error) {
} }
// Search will search a mailbox for search terms. // 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> // 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 `!` // Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string, start, limit int) ([]MessageSummary, error) { func Search(search string, start, limit int) ([]MessageSummary, error) {
@ -886,7 +891,7 @@ func IsUnread(id string) bool {
return unread == 1 return unread == 1
} }
// MessageIDExists blaah // MessageIDExists checks whether a Message-ID exists in the DB
func MessageIDExists(id string) bool { func MessageIDExists(id string) bool {
var total int var total int

View File

@ -3,23 +3,21 @@ package storage
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"regexp"
"sort" "sort"
"strings" "strings"
"github.com/axllent/mailpit/config" "github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger" "github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/leporo/sqlf" "github.com/leporo/sqlf"
) )
// SetTags will set the tags for a given database ID, used via API // SetTags will set the tags for a given database ID, used via API
func SetTags(id string, tags []string) error { func SetTags(id string, tags []string) error {
applyTags := []string{} applyTags := []string{}
reg := regexp.MustCompile(`\s+`)
for _, t := range tags { for _, t := range tags {
t = strings.TrimSpace(reg.ReplaceAllString(t, " ")) t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
if t != "" && config.TagRegexp.MatchString(t) && !inArray(t, applyTags) {
applyTags = append(applyTags, t) applyTags = append(applyTags, t)
} }
} }
@ -42,23 +40,22 @@ func SetTags(id string, tags []string) error {
return err return err
} }
// Used to auto-apply tags to new messages // Find tags set via --tags in raw message.
func findTags(message *[]byte) []string { // Returns a comma-separated string.
tags := []string{} func findTagsInRawMessage(message *[]byte) string {
tagStr := ""
if len(config.SMTPTags) == 0 { if len(config.SMTPTags) == 0 {
return tags return tagStr
} }
str := strings.ToLower(string(*message)) str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags { for _, t := range config.SMTPTags {
if !inArray(t.Tag, tags) && strings.Contains(str, t.Match) { if strings.Contains(str, t.Match) {
tags = append(tags, t.Tag) tagStr += "," + t.Tag
} }
} }
sort.Strings(tags) return tagStr
return tags
} }
// Get message tags from the database for a given database ID // Get message tags from the database for a given database ID
@ -84,3 +81,31 @@ func getMessageTags(id string) []string {
return tags 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
}

View File

@ -75,7 +75,7 @@ func dbCron() {
time.Sleep(60 * time.Second) time.Sleep(60 * time.Second)
start := time.Now() 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 // for 5 minutes, if so VACUUM
currentTime := time.Now() currentTime := time.Now()
diff := currentTime.Sub(dbLastAction) diff := currentTime.Sub(dbLastAction)
@ -167,6 +167,7 @@ func isFile(path string) bool {
return true return true
} }
// InArray tests if a string in within an array. It is not case sensitive.
func inArray(k string, arr []string) bool { func inArray(k string, arr []string) bool {
k = strings.ToLower(k) k = strings.ToLower(k)
for _, v := range arr { for _, v := range arr {

View File

@ -1,4 +1,4 @@
// Package tools provides various methods for variouws things // Package tools provides various methods for various things
package tools package tools
import ( import (
@ -23,7 +23,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
reBlank := regexp.MustCompile(`^\s+`) reBlank := regexp.MustCompile(`^\s+`)
for _, hdr := range headers { for _, hdr := range headers {
// case-insentitive // case-insensitive
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":")) reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
// header := []byte(hdr + ":") // header := []byte(hdr + ":")

25
utils/tools/tags.go Normal file
View 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
}