Skip to content

Commit

Permalink
Merge branch 'feature/unread' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
axllent committed Jul 30, 2022
2 parents 8d6d48c + a85a74b commit ce23e06
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 50 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
- Runs completely on a single binary
- 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`)
- 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
- 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
Expand Down
6 changes: 6 additions & 0 deletions data/mailbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ type WebsocketNotification struct {
Type string
Data interface{}
}

// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
}
33 changes: 11 additions & 22 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (
)

type messagesResult struct {
Total int `json:"total"`
Count int `json:"count"`
Start int `json:"start"`
Items []data.Summary `json:"items"`
Total int `json:"total"`
Unread int `json:"unread"`
Count int `json:"count"`
Start int `json:"start"`
Items []data.Summary `json:"items"`
}

// Return a list of available mailboxes
Expand Down Expand Up @@ -49,18 +50,15 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) {
return
}

total, err := storage.Count(mailbox)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet(mailbox)

var res messagesResult

res.Start = start
res.Items = messages
res.Count = len(res.Items)
res.Total = total
res.Total = stats.Total
res.Unread = stats.Unread

bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
Expand Down Expand Up @@ -92,24 +90,15 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
return
}

total, err := storage.Count(mailbox)
if err != nil {
httpError(w, err.Error())
return
}

// total := limit
// count := len(messages)
// if total > count {
// total = count
// }
stats := storage.StatsGet(mailbox)

var res messagesResult

res.Start = start
res.Items = messages
res.Count = len(messages)
res.Total = total
res.Total = stats.Total
res.Unread = stats.Unread

bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
Expand Down
12 changes: 9 additions & 3 deletions server/ui-src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default {
items: [],
limit: 50,
total: 0,
unread: 0,
start: 0,
search: "",
searching: false,
Expand Down Expand Up @@ -71,6 +72,7 @@ export default {
self.get(uri, params, function(response){
self.total = response.data.total;
self.unread = response.data.unread;
self.count = response.data.count;
self.start = response.data.start;
self.items = response.data.items;
Expand Down Expand Up @@ -119,7 +121,10 @@ export default {
self.get(uri, params, function(response) {
for (let i in self.items) {
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;
Expand Down Expand Up @@ -208,6 +213,7 @@ export default {
}
}
self.total++;
self.unread++;
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
self.scrollInPlace = true;
Expand Down Expand Up @@ -323,8 +329,8 @@ export default {
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
Inbox
<span class="position-absolute mt-2 ms-4 start-100 translate-middle badge rounded-pill text-bg-secondary" v-if="total">
{{ formatNumber(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(unread) }}
</span>
</a>
</li>
Expand Down
49 changes: 26 additions & 23 deletions storage/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -99,24 +100,20 @@ func ListMailboxes() ([]data.MailboxSummary, error) {
results := []data.MailboxSummary{}

for _, m := range mailboxes {

total, err := Count(m)
if err != nil {
return nil, err
// ignore *_data collections
if strings.HasSuffix(m, "_data") {
continue
}

unread, err := CountUnread(m)
if err != nil {
return nil, err
}
stats := StatsGet(m)

mb := data.MailboxSummary{}
mb.Name = m
mb.Slug = m
mb.Total = total
mb.Unread = unread
mb.Total = stats.Total
mb.Unread = stats.Unread

if total > 0 {
if mb.Total > 0 {
q, err := db.FindFirst(
clover.NewQuery(m).Sort(clover.SortOption{Field: "Created", Direction: -1}),
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -223,6 +220,8 @@ func Store(mailbox string, b []byte) (string, error) {
return "", err
}

statsAddNewMessage(mailbox)

count++
if count%100 == 0 {
logger.Log().Infof("%d messages added (%s per 100)", count, time.Since(per100start))
Expand Down Expand Up @@ -441,11 +440,16 @@ func GetMessage(mailbox, id string) (*data.Message, error) {

obj.HTML = html

updates := make(map[string]interface{})
updates["Read"] = true
msg, err := db.FindById(mailbox, id)
if err == nil && !msg.Get("Read").(bool) {
updates := make(map[string]interface{})
updates["Read"] = true

if err := db.UpdateById(mailbox, id, updates); err != nil {
return nil, err
if err := db.UpdateById(mailbox, id, updates); err != nil {
return nil, err
}

statsReadOneMessage(mailbox)
}

return &obj, nil
Expand Down Expand Up @@ -507,6 +511,8 @@ func UnreadMessage(mailbox, id string) error {
updates := make(map[string]interface{})
updates["Read"] = false

statsUnreadOneMessage(mailbox)

return db.UpdateById(mailbox, id, updates)
}

Expand All @@ -516,6 +522,8 @@ func DeleteOneMessage(mailbox, id string) error {
return err
}

statsDeleteOneMessage(mailbox)

return db.DeleteById(mailbox+"_data", id)
}

Expand Down Expand Up @@ -545,13 +553,8 @@ func DeleteAllMessages(mailbox string) error {
}
}

// if err := db.Delete(clover.NewQuery(mailbox)); err != nil {
// return err
// }

// if err := db.Delete(clover.NewQuery(mailbox + "_data")); err != nil {
// return err
// }
// resets stats for mailbox
statsRefresh(mailbox)

elapsed := time.Since(totalStart)
logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed)
Expand Down
103 changes: 103 additions & 0 deletions storage/stats.go
Original file line number Diff line number Diff line change
@@ -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()
}
3 changes: 2 additions & 1 deletion storage/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,12 @@ func pruneCron() {
if err := db.Delete(clover.NewQuery(m).
Sort(clover.SortOption{Field: "Created", Direction: 1}).
Limit(limit)); err != nil {
logger.Log().Warnf("Error pruning: %s", err.Error())
logger.Log().Warnf("Error pruning %s: %s", m, err.Error())
continue
}
elapsed := time.Since(start)
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
statsRefresh(m)
if !strings.HasSuffix(m, "_data") {
websockets.Broadcast("prune", nil)
}
Expand Down

0 comments on commit ce23e06

Please sign in to comment.