mirror of
https://github.com/axllent/mailpit.git
synced 2025-01-16 02:47:11 +02:00
Merge branch 'feature/unread' into develop
This commit is contained in:
commit
ce23e0616e
@ -12,7 +12,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
|
|||||||
- Runs completely on a single binary
|
- Runs completely on a single binary
|
||||||
- SMTP server (default `0.0.0.0:1025`)
|
- SMTP server (default `0.0.0.0:1025`)
|
||||||
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
|
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
|
||||||
- Real-time web UI updates using websockets for new mail
|
- Real-time web UI updates using web sockets for new mail
|
||||||
- Email storage in either memory or disk (using [CloverDB](https://github.com/ostafen/clover)) - note that in-memory has a physical limit of 1MB per email
|
- Email storage in either memory or disk (using [CloverDB](https://github.com/ostafen/clover)) - note that in-memory has a physical limit of 1MB per email
|
||||||
- Configurable automatic email pruning (default keeps the most recent 500 emails)
|
- Configurable automatic email pruning (default keeps the most recent 500 emails)
|
||||||
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
|
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
|
||||||
|
@ -16,3 +16,9 @@ type WebsocketNotification struct {
|
|||||||
Type string
|
Type string
|
||||||
Data interface{}
|
Data interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MailboxStats struct for quick mailbox total/read lookups
|
||||||
|
type MailboxStats struct {
|
||||||
|
Total int
|
||||||
|
Unread int
|
||||||
|
}
|
||||||
|
@ -12,10 +12,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type messagesResult struct {
|
type messagesResult struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Count int `json:"count"`
|
Unread int `json:"unread"`
|
||||||
Start int `json:"start"`
|
Count int `json:"count"`
|
||||||
Items []data.Summary `json:"items"`
|
Start int `json:"start"`
|
||||||
|
Items []data.Summary `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a list of available mailboxes
|
// Return a list of available mailboxes
|
||||||
@ -49,18 +50,15 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := storage.Count(mailbox)
|
stats := storage.StatsGet(mailbox)
|
||||||
if err != nil {
|
|
||||||
httpError(w, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var res messagesResult
|
var res messagesResult
|
||||||
|
|
||||||
res.Start = start
|
res.Start = start
|
||||||
res.Items = messages
|
res.Items = messages
|
||||||
res.Count = len(res.Items)
|
res.Count = len(res.Items)
|
||||||
res.Total = total
|
res.Total = stats.Total
|
||||||
|
res.Unread = stats.Unread
|
||||||
|
|
||||||
bytes, _ := json.Marshal(res)
|
bytes, _ := json.Marshal(res)
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
@ -92,24 +90,15 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := storage.Count(mailbox)
|
stats := storage.StatsGet(mailbox)
|
||||||
if err != nil {
|
|
||||||
httpError(w, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// total := limit
|
|
||||||
// count := len(messages)
|
|
||||||
// if total > count {
|
|
||||||
// total = count
|
|
||||||
// }
|
|
||||||
|
|
||||||
var res messagesResult
|
var res messagesResult
|
||||||
|
|
||||||
res.Start = start
|
res.Start = start
|
||||||
res.Items = messages
|
res.Items = messages
|
||||||
res.Count = len(messages)
|
res.Count = len(messages)
|
||||||
res.Total = total
|
res.Total = stats.Total
|
||||||
|
res.Unread = stats.Unread
|
||||||
|
|
||||||
bytes, _ := json.Marshal(res)
|
bytes, _ := json.Marshal(res)
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
@ -15,6 +15,7 @@ export default {
|
|||||||
items: [],
|
items: [],
|
||||||
limit: 50,
|
limit: 50,
|
||||||
total: 0,
|
total: 0,
|
||||||
|
unread: 0,
|
||||||
start: 0,
|
start: 0,
|
||||||
search: "",
|
search: "",
|
||||||
searching: false,
|
searching: false,
|
||||||
@ -71,6 +72,7 @@ export default {
|
|||||||
|
|
||||||
self.get(uri, params, function(response){
|
self.get(uri, params, function(response){
|
||||||
self.total = response.data.total;
|
self.total = response.data.total;
|
||||||
|
self.unread = response.data.unread;
|
||||||
self.count = response.data.count;
|
self.count = response.data.count;
|
||||||
self.start = response.data.start;
|
self.start = response.data.start;
|
||||||
self.items = response.data.items;
|
self.items = response.data.items;
|
||||||
@ -119,7 +121,10 @@ export default {
|
|||||||
self.get(uri, params, function(response) {
|
self.get(uri, params, 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) {
|
||||||
self.items[i].Read = true;
|
if (!self.items[i].Read) {
|
||||||
|
self.items[i].Read = true;
|
||||||
|
self.unread--;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let d = response.data;
|
let d = response.data;
|
||||||
@ -208,6 +213,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.total++;
|
self.total++;
|
||||||
|
self.unread++;
|
||||||
} else if (response.Type == "prune") {
|
} else if (response.Type == "prune") {
|
||||||
// messages have been deleted, reload messages to adjust
|
// messages have been deleted, reload messages to adjust
|
||||||
self.scrollInPlace = true;
|
self.scrollInPlace = true;
|
||||||
@ -323,8 +329,8 @@ export default {
|
|||||||
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
|
<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
|
Inbox
|
||||||
<span class="position-absolute mt-2 ms-4 start-100 translate-middle badge rounded-pill text-bg-secondary" v-if="total">
|
<span class="position-absolute mt-2 ms-4 start-100 translate-middle badge rounded-pill text-bg-secondary" title="Unread messages" v-if="unread">
|
||||||
{{ formatNumber(total) }}
|
{{ formatNumber(unread) }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -99,24 +100,20 @@ func ListMailboxes() ([]data.MailboxSummary, error) {
|
|||||||
results := []data.MailboxSummary{}
|
results := []data.MailboxSummary{}
|
||||||
|
|
||||||
for _, m := range mailboxes {
|
for _, m := range mailboxes {
|
||||||
|
// ignore *_data collections
|
||||||
total, err := Count(m)
|
if strings.HasSuffix(m, "_data") {
|
||||||
if err != nil {
|
continue
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unread, err := CountUnread(m)
|
stats := StatsGet(m)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
mb := data.MailboxSummary{}
|
mb := data.MailboxSummary{}
|
||||||
mb.Name = m
|
mb.Name = m
|
||||||
mb.Slug = m
|
mb.Slug = m
|
||||||
mb.Total = total
|
mb.Total = stats.Total
|
||||||
mb.Unread = unread
|
mb.Unread = stats.Unread
|
||||||
|
|
||||||
if total > 0 {
|
if mb.Total > 0 {
|
||||||
q, err := db.FindFirst(
|
q, err := db.FindFirst(
|
||||||
clover.NewQuery(m).Sort(clover.SortOption{Field: "Created", Direction: -1}),
|
clover.NewQuery(m).Sort(clover.SortOption{Field: "Created", Direction: -1}),
|
||||||
)
|
)
|
||||||
@ -172,7 +169,7 @@ func CreateMailbox(name string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return statsRefresh(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store will store a message in the database and return the unique ID
|
// Store will store a message in the database and return the unique ID
|
||||||
@ -223,6 +220,8 @@ func Store(mailbox string, b []byte) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsAddNewMessage(mailbox)
|
||||||
|
|
||||||
count++
|
count++
|
||||||
if count%100 == 0 {
|
if count%100 == 0 {
|
||||||
logger.Log().Infof("%d messages added (%s per 100)", count, time.Since(per100start))
|
logger.Log().Infof("%d messages added (%s per 100)", count, time.Since(per100start))
|
||||||
@ -441,11 +440,16 @@ func GetMessage(mailbox, id string) (*data.Message, error) {
|
|||||||
|
|
||||||
obj.HTML = html
|
obj.HTML = html
|
||||||
|
|
||||||
updates := make(map[string]interface{})
|
msg, err := db.FindById(mailbox, id)
|
||||||
updates["Read"] = true
|
if err == nil && !msg.Get("Read").(bool) {
|
||||||
|
updates := make(map[string]interface{})
|
||||||
|
updates["Read"] = true
|
||||||
|
|
||||||
if err := db.UpdateById(mailbox, id, updates); err != nil {
|
if err := db.UpdateById(mailbox, id, updates); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
statsReadOneMessage(mailbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &obj, nil
|
return &obj, nil
|
||||||
@ -507,6 +511,8 @@ func UnreadMessage(mailbox, id string) error {
|
|||||||
updates := make(map[string]interface{})
|
updates := make(map[string]interface{})
|
||||||
updates["Read"] = false
|
updates["Read"] = false
|
||||||
|
|
||||||
|
statsUnreadOneMessage(mailbox)
|
||||||
|
|
||||||
return db.UpdateById(mailbox, id, updates)
|
return db.UpdateById(mailbox, id, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -516,6 +522,8 @@ func DeleteOneMessage(mailbox, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsDeleteOneMessage(mailbox)
|
||||||
|
|
||||||
return db.DeleteById(mailbox+"_data", id)
|
return db.DeleteById(mailbox+"_data", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -545,13 +553,8 @@ func DeleteAllMessages(mailbox string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if err := db.Delete(clover.NewQuery(mailbox)); err != nil {
|
// resets stats for mailbox
|
||||||
// return err
|
statsRefresh(mailbox)
|
||||||
// }
|
|
||||||
|
|
||||||
// if err := db.Delete(clover.NewQuery(mailbox + "_data")); err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
elapsed := time.Since(totalStart)
|
elapsed := time.Since(totalStart)
|
||||||
logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed)
|
logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed)
|
||||||
|
103
storage/stats.go
Normal file
103
storage/stats.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/axllent/mailpit/data"
|
||||||
|
"github.com/axllent/mailpit/logger"
|
||||||
|
"github.com/ostafen/clover/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mailboxStats = map[string]data.MailboxStats{}
|
||||||
|
statsLock = sync.RWMutex{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsGet returns the total/unread statistics for a mailbox
|
||||||
|
func StatsGet(mailbox string) data.MailboxStats {
|
||||||
|
statsLock.Lock()
|
||||||
|
defer statsLock.Unlock()
|
||||||
|
s, ok := mailboxStats[mailbox]
|
||||||
|
if !ok {
|
||||||
|
return data.MailboxStats{
|
||||||
|
Total: 0,
|
||||||
|
Unread: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh will completely refresh the existing stats for a given mailbox
|
||||||
|
func statsRefresh(mailbox string) error {
|
||||||
|
logger.Log().Debugf("[stats] refreshing stats for %s", mailbox)
|
||||||
|
|
||||||
|
total, err := db.Count(clover.NewQuery(mailbox))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
unread, err := db.Count(clover.NewQuery(mailbox).Where(clover.Field("Read").IsFalse()))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
statsLock.Lock()
|
||||||
|
mailboxStats[mailbox] = data.MailboxStats{
|
||||||
|
Total: total,
|
||||||
|
Unread: unread,
|
||||||
|
}
|
||||||
|
statsLock.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsAddNewMessage(mailbox string) {
|
||||||
|
statsLock.Lock()
|
||||||
|
s, ok := mailboxStats[mailbox]
|
||||||
|
if ok {
|
||||||
|
mailboxStats[mailbox] = data.MailboxStats{
|
||||||
|
Total: s.Total + 1,
|
||||||
|
Unread: s.Unread + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting one will always mean it was read
|
||||||
|
func statsDeleteOneMessage(mailbox string) {
|
||||||
|
statsLock.Lock()
|
||||||
|
s, ok := mailboxStats[mailbox]
|
||||||
|
if ok {
|
||||||
|
mailboxStats[mailbox] = data.MailboxStats{
|
||||||
|
Total: s.Total - 1,
|
||||||
|
Unread: s.Unread,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark one message as read
|
||||||
|
func statsReadOneMessage(mailbox string) {
|
||||||
|
statsLock.Lock()
|
||||||
|
s, ok := mailboxStats[mailbox]
|
||||||
|
if ok {
|
||||||
|
mailboxStats[mailbox] = data.MailboxStats{
|
||||||
|
Total: s.Total,
|
||||||
|
Unread: s.Unread - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark one message as unread
|
||||||
|
func statsUnreadOneMessage(mailbox string) {
|
||||||
|
statsLock.Lock()
|
||||||
|
s, ok := mailboxStats[mailbox]
|
||||||
|
if ok {
|
||||||
|
mailboxStats[mailbox] = data.MailboxStats{
|
||||||
|
Total: s.Total,
|
||||||
|
Unread: s.Unread + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsLock.Unlock()
|
||||||
|
}
|
@ -76,11 +76,12 @@ func pruneCron() {
|
|||||||
if err := db.Delete(clover.NewQuery(m).
|
if err := db.Delete(clover.NewQuery(m).
|
||||||
Sort(clover.SortOption{Field: "Created", Direction: 1}).
|
Sort(clover.SortOption{Field: "Created", Direction: 1}).
|
||||||
Limit(limit)); err != nil {
|
Limit(limit)); err != nil {
|
||||||
logger.Log().Warnf("Error pruning: %s", err.Error())
|
logger.Log().Warnf("Error pruning %s: %s", m, err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
|
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
|
||||||
|
statsRefresh(m)
|
||||||
if !strings.HasSuffix(m, "_data") {
|
if !strings.HasSuffix(m, "_data") {
|
||||||
websockets.Broadcast("prune", nil)
|
websockets.Broadcast("prune", nil)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user