mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 06:57:01 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae51c3f64 | ||
|
|
b6a87b9410 | ||
|
|
1f7dd0287a | ||
|
|
f33cbce63f | ||
|
|
79b6892320 | ||
|
|
799987ecb1 | ||
|
|
2d57839b3e | ||
|
|
86cc237c78 | ||
|
|
cc15ada304 | ||
|
|
49bc62f0aa | ||
|
|
444b65d371 | ||
|
|
15859f7be9 | ||
|
|
486388a798 | ||
|
|
9ab28d606a | ||
|
|
18b5ce8c18 | ||
|
|
93d5289d25 | ||
|
|
97bf9c257c | ||
|
|
18b0f5b790 | ||
|
|
94feb2ccaa | ||
|
|
aba3c46eb1 | ||
|
|
c9c910ab7c | ||
|
|
29c7295d16 | ||
|
|
61e15e4155 | ||
|
|
e03618570d | ||
|
|
d4cf95363f | ||
|
|
f260495495 | ||
|
|
d9f1f88107 | ||
|
|
09b704bcd7 | ||
|
|
a14cdce07f | ||
|
|
9fc5318e86 | ||
|
|
8affa0f375 | ||
|
|
cf8994ceaf | ||
|
|
39132723db | ||
|
|
642487742c | ||
|
|
544f0175d9 | ||
|
|
788e390e01 | ||
|
|
f6ae6bbdbb | ||
|
|
1155443785 | ||
|
|
056bef7d5e | ||
|
|
37eec298d7 | ||
|
|
a77b532328 | ||
|
|
00d6463de1 | ||
|
|
a3b92711a9 | ||
|
|
ba8c4cd2aa |
@@ -1,9 +1,9 @@
|
||||
name: Test
|
||||
name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
push:
|
||||
branches: [ develop ]
|
||||
branches: [ develop, 'feature/**' ]
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -3,6 +3,51 @@
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Feature
|
||||
- Email compression in storage
|
||||
|
||||
### Testing
|
||||
- Enable testing on feature branches
|
||||
- Database total/unread statistics tests
|
||||
|
||||
### UI
|
||||
- Mobile compatibility improvements & functionality
|
||||
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Feature
|
||||
- Mark all messages as read
|
||||
|
||||
### UI
|
||||
- Better error handling when connection to server is broken
|
||||
- Add reset search button
|
||||
- Minor UI tweaks
|
||||
- Update pagination values when new mail arrives when not on first page
|
||||
|
||||
|
||||
### Pull Requests
|
||||
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Feature
|
||||
- Optional browser notifications (HTTPS only)
|
||||
|
||||
### Security
|
||||
- Don't allow tar files containing a ".."
|
||||
- Sanitize mailbox names
|
||||
- Use strconv.Atoi() for safe string to int conversions
|
||||
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Bugfix
|
||||
- Fix env variable for MP_UI_SSL_KEY
|
||||
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Feature
|
||||
|
||||
12
README.md
12
README.md
@@ -1,5 +1,11 @@
|
||||
# Mailpit
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/axllent/mailpit)
|
||||
|
||||
Mailpit is an email testing tool for developers.
|
||||
|
||||
It acts as both an SMTP server, and provides a web interface to view all captured emails.
|
||||
@@ -15,6 +21,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
|
||||
- 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 web sockets for new mail
|
||||
- Optional browser notifications for new mail (HTTPS only)
|
||||
- Configurable automatic email pruning (default keeps the most recent 500 emails)
|
||||
- Email storage either in memory or disk ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
|
||||
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
|
||||
@@ -25,11 +32,6 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
|
||||
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
|
||||
|
||||
|
||||
## Planned features
|
||||
|
||||
- Browser notifications for new mail (HTTPS only)
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options.
|
||||
|
||||
28
cmd/root.go
28
cmd/root.go
@@ -84,15 +84,29 @@ func init() {
|
||||
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
|
||||
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
|
||||
}
|
||||
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
|
||||
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
|
||||
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
|
||||
config.SMTPSSLCert = os.Getenv("MP_SMTP_SSL_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
|
||||
config.SMTPSSLKey = os.Getenv("MP_SMTP_SSL_KEY")
|
||||
}
|
||||
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
|
||||
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
|
||||
}
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_SSL_CERT")) > 0 {
|
||||
config.UISSLCert = os.Getenv("MP_SSL_CERT")
|
||||
@@ -101,14 +115,8 @@ func init() {
|
||||
if len(os.Getenv("MP_SSL_KEY")) > 0 {
|
||||
config.UISSLKey = os.Getenv("MP_SSL_KEY")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_UISSL_KEY")) > 0 {
|
||||
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data")
|
||||
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store persistent data")
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
|
||||
@@ -17,7 +17,6 @@ type Message struct {
|
||||
Bcc []*mail.Address
|
||||
Subject string
|
||||
Date time.Time
|
||||
Created time.Time
|
||||
Text string
|
||||
HTML string
|
||||
Size int
|
||||
|
||||
2
go.mod
2
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jhillyerd/enmime v0.10.0
|
||||
github.com/k3a/html2text v1.0.8
|
||||
github.com/klauspost/compress v1.15.9
|
||||
github.com/mhale/smtpd v0.8.0
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.2
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
@@ -35,7 +36,6 @@ require (
|
||||
github.com/google/orderedcode v0.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/klauspost/compress v1.15.9 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 63 KiB |
@@ -29,7 +29,7 @@ func apiListMailboxes(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -62,7 +62,7 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -102,7 +102,7 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Open a message
|
||||
@@ -120,7 +120,7 @@ func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Download/view an attachment
|
||||
@@ -143,7 +143,7 @@ func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
w.Write(a.Content)
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
// View the full email source as plain text
|
||||
@@ -165,7 +165,7 @@ func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
w.Write(data)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// Delete all messages in the mailbox
|
||||
@@ -181,7 +181,7 @@ func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Delete a single message
|
||||
@@ -198,7 +198,7 @@ func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Mark single message as unread
|
||||
@@ -215,7 +215,23 @@ func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Mark single message as unread
|
||||
func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
err := storage.MarkAllRead(mailbox)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes
|
||||
|
||||
@@ -39,6 +39,7 @@ func Listen() {
|
||||
r.HandleFunc("/api/{mailbox}/search", middleWareFunc(apiSearchMailbox))
|
||||
r.HandleFunc("/api/{mailbox}/delete", middleWareFunc(apiDeleteAll))
|
||||
r.HandleFunc("/api/{mailbox}/events", apiWebsocket)
|
||||
r.HandleFunc("/api/{mailbox}/read", apiMarkAllRead)
|
||||
r.HandleFunc("/api/{mailbox}/{id}/source", middleWareFunc(apiDownloadSource))
|
||||
r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment))
|
||||
r.HandleFunc("/api/{mailbox}/{id}/delete", middleWareFunc(apiDeleteOne))
|
||||
@@ -64,7 +65,7 @@ func Listen() {
|
||||
func basicAuthResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorised.\n"))
|
||||
_, _ = w.Write([]byte("Unauthorised.\n"))
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
@@ -156,16 +157,13 @@ func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
limit = 50
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 {
|
||||
start = int(n)
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
start = n
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, e := strconv.ParseInt(l, 10, 64); e == nil && n > 0 {
|
||||
if n > 500 {
|
||||
n = 500
|
||||
}
|
||||
limit = int(n)
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
|
||||
return start, limit
|
||||
|
||||
@@ -17,11 +17,14 @@ export default {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
start: 0,
|
||||
count: 0,
|
||||
search: "",
|
||||
searching: false,
|
||||
isConnected: false,
|
||||
scrollInPlace: false,
|
||||
message: false
|
||||
message: false,
|
||||
notificationsSupported: false,
|
||||
notificationsEnabled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -47,6 +50,10 @@ export default {
|
||||
this.currentPath = window.location.hash.slice(1);
|
||||
});
|
||||
|
||||
this.notificationsSupported = 'https:' == document.location.protocol
|
||||
&& ("Notification" in window && Notification.permission !== "denied");
|
||||
this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
|
||||
|
||||
this.connect();
|
||||
this.loadMessages();
|
||||
},
|
||||
@@ -93,6 +100,13 @@ export default {
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
resetSearch: function(e) {
|
||||
e.preventDefault();
|
||||
this.search = '';
|
||||
this.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
reloadMessages: function() {
|
||||
this.search = "";
|
||||
this.start = 0;
|
||||
@@ -192,6 +206,16 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
markAllRead: function() {
|
||||
let self = this;
|
||||
let uri = 'api/' + self.mailbox + '/read'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// websocket connect
|
||||
connect: function () {
|
||||
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
|
||||
@@ -204,16 +228,19 @@ export default {
|
||||
}
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
if (self.start < 1) {
|
||||
if (!self.searching) {
|
||||
if (!self.searching) {
|
||||
if (self.start < 1) {
|
||||
self.items.unshift(response.Data);
|
||||
if (self.items.length > self.limit) {
|
||||
self.items.pop();
|
||||
}
|
||||
} else {
|
||||
self.start++;
|
||||
}
|
||||
}
|
||||
self.total++;
|
||||
self.unread++;
|
||||
self.browserNotify("New mail from: " + response.Data.From.Address, response.Data.Subject);
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
self.scrollInPlace = true;
|
||||
@@ -252,45 +279,95 @@ export default {
|
||||
let d = new Date(message.Created)
|
||||
return moment(d).fromNow().toString();
|
||||
},
|
||||
|
||||
browserNotify: function(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let b = message.Subject;
|
||||
let options = {
|
||||
body: message,
|
||||
icon: 'mailpit.png'
|
||||
}
|
||||
new Notification(title, options);
|
||||
}
|
||||
},
|
||||
|
||||
requestNotifications: function() {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notification");
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
let self = this;
|
||||
Notification.requestPermission().then(function (permission) {
|
||||
// If the user accepts, let's create a notification
|
||||
if (permission === "granted") {
|
||||
self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received.");
|
||||
self.notificationsEnabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light">
|
||||
<div class="col-lg-2 col-md-3 col-auto">
|
||||
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light shadow-sm">
|
||||
<div class="col-lg-2 col-md-3 d-none d-md-block">
|
||||
<a class="navbar-brand" href="#" v-on:click="reloadMessages">
|
||||
<img src="mailpit.svg" alt="Mailpit">
|
||||
<span class="d-none d-md-inline-block ms-2">Mailpit</span>
|
||||
<span class="ms-2">Mailpit</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-9 col-lg-8" v-if="message">
|
||||
<div class="col col-md-9 col-lg-10" v-if="message">
|
||||
<a class="btn btn-outline-secondary me-4 px-3" href="#" v-on:click="message=false" title="Return to messages">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary me-2" title="Delete message" v-on:click="deleteOne">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<i class="bi bi-envelope"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
</button>
|
||||
<a :href="'api/' + mailbox + '/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2" title="Download message">
|
||||
<i class="bi bi-file-arrow-down-fill"></i>
|
||||
<a :href="'api/' + mailbox + '/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message">
|
||||
<i class="bi bi-file-arrow-down-fill"></i> <span class="d-none d-md-inline">Download</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-9 col-lg-5" v-if="!message && total">
|
||||
<div class="col col-md-9 col-lg-5 LOL" v-if="!message">
|
||||
<form v-on:submit="doSearch">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model.trim="search" placeholder="Search mailbox">
|
||||
<button class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
|
||||
<a class="navbar-brand d-md-none" href="#" v-on:click="reloadMessages">
|
||||
<img src="mailpit.svg" alt="Mailpit">
|
||||
<span v-if="!total" class="ms-2">Mailpit</span>
|
||||
</a>
|
||||
<div v-if="total" class="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">
|
||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search" v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||
</div>
|
||||
<button v-if="total" class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-12 col-lg-5 text-end" 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-outline-danger float-start d-md-none me-2" data-bs-toggle="modal" data-bs-target="#DeleteAllModal" title="Delete all messages">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
|
||||
<button v-if="unread" class="btn btn-outline-primary float-start d-md-none" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal" title="Mark all read">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
</button>
|
||||
|
||||
<select v-model="limit" v-on:change="loadMessages"
|
||||
class="form-select form-select-sm d-inline w-auto me-1" v-if="!searching">
|
||||
class="form-select form-select-sm d-inline w-auto me-2" v-if="!searching">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
@@ -303,7 +380,7 @@ export default {
|
||||
<small>
|
||||
<b>{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }}</b> of <b>{{ formatNumber(total) }}</b>
|
||||
</small>
|
||||
<button class="btn btn-outline-secondary ms-3 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
<button class="btn btn-outline-secondary ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
v-if="!searching">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
@@ -315,16 +392,16 @@ export default {
|
||||
</div>
|
||||
<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;">
|
||||
<ul class="list-unstyled mt-3">
|
||||
<li v-if="isConnected" title="Messages will auto-load">
|
||||
<ul class="list-unstyled mt-3 mb-5">
|
||||
<li v-if="isConnected" title="Messages will auto-load" class="mb-2">
|
||||
<i class="bi bi-power text-success"></i>
|
||||
Connected
|
||||
</li>
|
||||
<li v-else title="Messages will auto-load">
|
||||
<li v-else title="You need to manually refresh your mailbox" class="mb-3">
|
||||
<i class="bi bi-power text-danger"></i>
|
||||
Disconnected
|
||||
</li>
|
||||
<li class="mt-3">
|
||||
<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>
|
||||
@@ -334,14 +411,26 @@ export default {
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-3 mb-5">
|
||||
<a v-if="total" href="#" data-bs-toggle="modal" data-bs-target="#deleteAllModal">
|
||||
<li class="my-3" v-if="unread">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
Mark all read
|
||||
</a>
|
||||
</li>
|
||||
<li class="my-3" v-if="total">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-5 position-fixed bottom-0 w-100">
|
||||
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white py-2">
|
||||
<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>
|
||||
Enable alerts
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-5 position-fixed bottom-0">
|
||||
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white my-3">
|
||||
<i class="bi bi-github"></i>
|
||||
GitHub
|
||||
</a>
|
||||
@@ -360,13 +449,13 @@ export default {
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
|
||||
<div class="text-truncate d-md-none">
|
||||
<div class="text-truncate d-md-none privacy">
|
||||
<span v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</span>
|
||||
</div>
|
||||
<div class="text-truncate d-none d-md-block">
|
||||
<div class="text-truncate d-none d-md-block privacy">
|
||||
<b v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</b>
|
||||
</div>
|
||||
<div class="d-none d-md-block text-truncate text-muted small">
|
||||
<div class="d-none d-md-block text-truncate text-muted small privacy">
|
||||
{{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{message.To.length - 1}}]
|
||||
@@ -385,7 +474,14 @@ export default {
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else class="text-muted py-3">No messages</div>
|
||||
<div v-else class="text-muted my-3">
|
||||
<span v-if="searching">
|
||||
No results matching your search
|
||||
</span>
|
||||
<span v-else>
|
||||
There are no emails in your mailbox
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="message" :message="message" :mailbox="mailbox"></Message>
|
||||
@@ -400,11 +496,11 @@ export default {
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="deleteAllModal" tabindex="-1" aria-labelledby="deleteAllModalLabel" aria-hidden="true">
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteAllModalLabel">Delete all messages?</h5>
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -418,4 +514,46 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages 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(unread) }} message<span v-if="unread > 1">s</span> as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="markAllRead">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives a new mail?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click <code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="requestNotifications">Enable notifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
// @import "../../../node_modules/bootstrap-icons"; ///scss/root";
|
||||
|
||||
@import "bootstrap";
|
||||
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
.navbar {
|
||||
z-index: 99;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +35,6 @@
|
||||
}
|
||||
|
||||
.message.read:not(.active) {
|
||||
// background: $gray-100;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
@@ -52,3 +61,13 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
body.blur {
|
||||
.privacy {
|
||||
filter: blur(3px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const commonMixins = {
|
||||
// Ajax error message
|
||||
handleError: function (error) {
|
||||
// handle error
|
||||
if (error.response) {
|
||||
if (error.response && error.response.data) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
|
||||
@@ -78,7 +78,7 @@ export default {
|
||||
<tbody>
|
||||
<tr class="small">
|
||||
<th>From</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
|
||||
<span v-if="message.From.Address"><{{ message.From.Address }}></span>
|
||||
@@ -90,7 +90,7 @@ export default {
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.To">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
@@ -99,7 +99,7 @@ export default {
|
||||
</tr>
|
||||
<tr v-if="message.Cc" class="small">
|
||||
<th>CC</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
</tr>
|
||||
<tr v-if="message.Bcc" class="small">
|
||||
<th>CC</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
|
||||
BIN
server/ui/mailpit.png
Normal file
BIN
server/ui/mailpit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -133,5 +133,5 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||
func basicAuthResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorised.\n"))
|
||||
_, _ = w.Write([]byte("Unauthorised.\n"))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/ostafen/clover/v2"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,10 @@ var (
|
||||
|
||||
count int
|
||||
per100start = time.Now()
|
||||
|
||||
// zstd encoder & decoder
|
||||
encoder, _ = zstd.NewWriter(nil)
|
||||
decoder, _ = zstd.NewReader(nil)
|
||||
)
|
||||
|
||||
// CloverStore struct
|
||||
@@ -65,7 +70,7 @@ func InitDB() error {
|
||||
// method invoked upon seeing signal
|
||||
go func() {
|
||||
s := <-sigs
|
||||
logger.Log().Infof("[db] got %s signal, saving persistant data & shutting down", s)
|
||||
logger.Log().Infof("[db] got %s signal, saving persistent data & shutting down", s)
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
@@ -140,40 +145,44 @@ func MailboxExists(name string) bool {
|
||||
}
|
||||
|
||||
// CreateMailbox will create a collection if it does not exist
|
||||
func CreateMailbox(name string) error {
|
||||
if !MailboxExists(name) {
|
||||
logger.Log().Infof("[db] creating mailbox: %s", name)
|
||||
func CreateMailbox(mailbox string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
if err := db.CreateCollection(name); err != nil {
|
||||
if !MailboxExists(mailbox) {
|
||||
logger.Log().Infof("[db] creating mailbox: %s", mailbox)
|
||||
|
||||
if err := db.CreateCollection(mailbox); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create Created index
|
||||
if err := db.CreateIndex(name, "Created"); err != nil {
|
||||
if err := db.CreateIndex(mailbox, "Created"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create Read index
|
||||
if err := db.CreateIndex(name, "Read"); err != nil {
|
||||
if err := db.CreateIndex(mailbox, "Read"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create separate collection for data
|
||||
if err := db.CreateCollection(name + "_data"); err != nil {
|
||||
if err := db.CreateCollection(mailbox + "_data"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create Created index
|
||||
if err := db.CreateIndex(name+"_data", "Created"); err != nil {
|
||||
if err := db.CreateIndex(mailbox+"_data", "Created"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return statsRefresh(name)
|
||||
return statsRefresh(mailbox)
|
||||
}
|
||||
|
||||
// Store will store a message in the database and return the unique ID
|
||||
func Store(mailbox string, b []byte) (string, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
r := bytes.NewReader(b)
|
||||
// Parse message body with enmime.
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
@@ -215,12 +224,15 @@ func Store(mailbox string, b []byte) (string, error) {
|
||||
raw := clover.NewDocument()
|
||||
raw.Set("_id", id)
|
||||
raw.Set("Created", time.Now())
|
||||
raw.Set("Data", string(b))
|
||||
|
||||
compressed := encoder.EncodeAll(b, make([]byte, 0, len(b)))
|
||||
raw.Set("Email", string(compressed))
|
||||
|
||||
_, err = db.InsertOne(mailbox+"_data", raw)
|
||||
if err != nil {
|
||||
// delete the summary because the data insert failed
|
||||
logger.Log().Debugf("[db] error inserting raw message, rolling back")
|
||||
DeleteOneMessage(mailbox, id)
|
||||
_ = DeleteOneMessage(mailbox, id)
|
||||
|
||||
return "", err
|
||||
}
|
||||
@@ -254,6 +266,8 @@ func Store(mailbox string, b []byte) (string, error) {
|
||||
// as clover's `Skip()` returns a subset of all results which is much slower.
|
||||
// @see https://github.com/ostafen/clover/issues/73
|
||||
func List(mailbox string, start, limit int) ([]data.Summary, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
var lastDoc *clover.Document
|
||||
count := 0
|
||||
startAddingAt := start + 1
|
||||
@@ -314,6 +328,8 @@ func List(mailbox string, start, limit int) ([]data.Summary, error) {
|
||||
|
||||
// Search returns a summary of items mathing a search. It searched the SearchText field.
|
||||
func Search(mailbox, search string, start, limit int) ([]data.Summary, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
sq := fmt.Sprintf("(?i)%s", cleanString(regexp.QuoteMeta(search)))
|
||||
q, err := db.FindAll(clover.NewQuery(mailbox).
|
||||
Skip(start).
|
||||
@@ -340,11 +356,15 @@ func Search(mailbox, search string, start, limit int) ([]data.Summary, error) {
|
||||
|
||||
// Count returns the total number of messages in a mailbox
|
||||
func Count(mailbox string) (int, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
return db.Count(clover.NewQuery(mailbox))
|
||||
}
|
||||
|
||||
// CountUnread returns the unread number of messages in a mailbox
|
||||
func CountUnread(mailbox string) (int, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
return db.Count(
|
||||
clover.NewQuery(mailbox).
|
||||
Where(clover.Field("Read").IsFalse()),
|
||||
@@ -355,18 +375,14 @@ func CountUnread(mailbox string) (int, error) {
|
||||
// ID must be supplied as this is not stored within the CloverStore but rather the
|
||||
// *clover.Document
|
||||
func GetMessage(mailbox, id string) (*data.Message, error) {
|
||||
q, err := db.FindById(mailbox+"_data", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
if q == nil {
|
||||
raw, err := GetMessageRaw(mailbox, id)
|
||||
if err != nil || raw == nil {
|
||||
return nil, errors.New("message not found")
|
||||
}
|
||||
|
||||
raw := q.Get("Data").(string)
|
||||
|
||||
r := bytes.NewReader([]byte(raw))
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
@@ -384,9 +400,8 @@ func GetMessage(mailbox, id string) (*data.Message, error) {
|
||||
date, _ := env.Date()
|
||||
|
||||
obj := data.Message{
|
||||
ID: q.ObjectId(),
|
||||
ID: id,
|
||||
Read: true,
|
||||
Created: q.Get("Created").(time.Time),
|
||||
From: from,
|
||||
Date: date,
|
||||
To: addressToSlice(env, "To"),
|
||||
@@ -440,12 +455,14 @@ func GetMessage(mailbox, id string) (*data.Message, error) {
|
||||
|
||||
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
|
||||
func GetAttachmentPart(mailbox, id, partID string) (*enmime.Part, error) {
|
||||
data, err := GetMessageRaw(mailbox, id)
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
raw, err := GetMessageRaw(mailbox, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(data)
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
@@ -475,6 +492,8 @@ func GetAttachmentPart(mailbox, id, partID string) (*enmime.Part, error) {
|
||||
|
||||
// GetMessageRaw returns an []byte of the full message
|
||||
func GetMessageRaw(mailbox, id string) ([]byte, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
q, err := db.FindById(mailbox+"_data", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -484,13 +503,26 @@ func GetMessageRaw(mailbox, id string) ([]byte, error) {
|
||||
return nil, errors.New("message not found")
|
||||
}
|
||||
|
||||
data := q.Get("Data").(string)
|
||||
var raw []byte
|
||||
|
||||
return []byte(data), err
|
||||
if q.Has("Email") {
|
||||
msg := q.Get("Email").(string)
|
||||
raw, err = decoder.DecodeAll([]byte(msg), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
} else {
|
||||
// deprecated 2022/08/10 - can be eventually removed
|
||||
raw = []byte(q.Get("Data").(string))
|
||||
}
|
||||
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// UnreadMessage will delete all messages from a mailbox
|
||||
func UnreadMessage(mailbox, id string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
updates["Read"] = false
|
||||
|
||||
@@ -501,6 +533,8 @@ func UnreadMessage(mailbox, id string) error {
|
||||
|
||||
// DeleteOneMessage will delete a single message from a mailbox
|
||||
func DeleteOneMessage(mailbox, id string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
q, err := db.FindById(mailbox, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -519,6 +553,7 @@ func DeleteOneMessage(mailbox, id string) error {
|
||||
|
||||
// DeleteAllMessages will delete all messages from a mailbox
|
||||
func DeleteAllMessages(mailbox string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
totalStart := time.Now()
|
||||
|
||||
@@ -544,10 +579,45 @@ func DeleteAllMessages(mailbox string) error {
|
||||
}
|
||||
|
||||
// resets stats for mailbox
|
||||
statsRefresh(mailbox)
|
||||
_ = statsRefresh(mailbox)
|
||||
|
||||
elapsed := time.Since(totalStart)
|
||||
logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllRead will mark every unread message in a mailbox as read
|
||||
func MarkAllRead(mailbox string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
totalStart := time.Now()
|
||||
|
||||
q, err := db.FindAll(clover.NewQuery(mailbox).
|
||||
Where(clover.Field("Read").IsFalse()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total := len(q)
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
updates["Read"] = true
|
||||
|
||||
for _, m := range q {
|
||||
if err := db.UpdateById(mailbox, m.ObjectId(), updates); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := statsRefresh(mailbox); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(totalStart)
|
||||
|
||||
logger.Log().Debugf("[db] marked %d messages in %s as read in %s", total, mailbox, elapsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/ostafen/clover/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
testTextEmail []byte
|
||||
testMimeEmail []byte
|
||||
testRuns = 1000
|
||||
)
|
||||
|
||||
func TestTextEmailInserts(t *testing.T) {
|
||||
@@ -25,7 +27,10 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
|
||||
RepeatTest:
|
||||
start := time.Now()
|
||||
for i := 0; i < 1000; i++ {
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -38,9 +43,11 @@ RepeatTest:
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, count, 1000, "incorrect number of text emails stored")
|
||||
assertEqual(t, count, testRuns, "incorrect number of text emails stored")
|
||||
|
||||
t.Logf("inserted 1,000 text emails in %s\n", time.Since(start))
|
||||
t.Logf("inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(DefaultMailbox); err != nil {
|
||||
@@ -56,7 +63,9 @@ RepeatTest:
|
||||
|
||||
assertEqual(t, count, 0, "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted 1,000 text emails in %s\n", time.Since(delStart))
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
db.Close()
|
||||
if config.DataDir == "" {
|
||||
@@ -74,7 +83,10 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
|
||||
RepeatTest:
|
||||
start := time.Now()
|
||||
for i := 0; i < 1000; i++ {
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -87,9 +99,11 @@ RepeatTest:
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, count, 1000, "incorrect number of mime emails stored")
|
||||
assertEqual(t, count, testRuns, "incorrect number of emails with mime attachments stored")
|
||||
|
||||
t.Logf("inserted 1,000 emails with mime attachments in %s\n", time.Since(start))
|
||||
t.Logf("inserted %d emails with mime attachments in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(DefaultMailbox); err != nil {
|
||||
@@ -103,9 +117,11 @@ RepeatTest:
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, count, 0, "incorrect number of mime emails deleted")
|
||||
assertEqual(t, count, 0, "incorrect number of emails with mime attachments deleted")
|
||||
|
||||
t.Logf("deleted 1,000 mime emails in %s\n", time.Since(delStart))
|
||||
t.Logf("deleted %d emails with mime attachments in %s", testRuns, time.Since(delStart))
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
db.Close()
|
||||
if config.DataDir == "" {
|
||||
@@ -158,12 +174,56 @@ RepeatTest:
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseStats(t *testing.T) {
|
||||
setup(false)
|
||||
t.Log("Testing database stats")
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqualStats(t, 100, 100)
|
||||
|
||||
// mark 10 as read
|
||||
docs, err := db.FindAll(
|
||||
clover.NewQuery(DefaultMailbox).
|
||||
Limit(10),
|
||||
)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
for _, d := range docs {
|
||||
_, err := GetMessage(DefaultMailbox, d.ObjectId())
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqualStats(t, 100, 90)
|
||||
|
||||
if err := MarkAllRead(DefaultMailbox); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqualStats(t, 100, 0)
|
||||
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
setup(false)
|
||||
t.Log("Testing memory storage")
|
||||
|
||||
RepeatTest:
|
||||
for i := 0; i < 1000; i++ {
|
||||
for i := 0; i < testRuns; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
@@ -189,7 +249,7 @@ RepeatTest:
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < 101; i++ {
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
searchIndx := rand.Intn(4) + 1
|
||||
var search string
|
||||
@@ -204,7 +264,7 @@ RepeatTest:
|
||||
search = fmt.Sprintf("the email body %d jdsauk dwqmdqw", i)
|
||||
}
|
||||
|
||||
summaries, err := Search(DefaultMailbox, search, 0, 200)
|
||||
summaries, err := Search(DefaultMailbox, search, 0, 10)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -220,12 +280,12 @@ RepeatTest:
|
||||
}
|
||||
|
||||
// search something that will return 200 rsults
|
||||
summaries, err := Search(DefaultMailbox, "This is the email body", 0, 200)
|
||||
summaries, err := Search(DefaultMailbox, "This is the email body", 0, 50)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(summaries), 200, "200 search results expected")
|
||||
assertEqual(t, len(summaries), 50, "50 search results expected")
|
||||
|
||||
db.Close()
|
||||
|
||||
@@ -296,3 +356,14 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet(DefaultMailbox)
|
||||
if total != s.Total {
|
||||
t.Fatal(fmt.Sprintf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total))
|
||||
}
|
||||
|
||||
if unread != s.Unread {
|
||||
t.Fatal(fmt.Sprintf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ var (
|
||||
|
||||
// StatsGet returns the total/unread statistics for a mailbox
|
||||
func StatsGet(mailbox string) data.MailboxStats {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
statsLock.Lock()
|
||||
defer statsLock.Unlock()
|
||||
s, ok := mailboxStats[mailbox]
|
||||
|
||||
@@ -56,11 +56,10 @@ func cleanString(str string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
|
||||
}
|
||||
|
||||
// Auto-prune runs every 5 minutes to automatically delete oldest messages
|
||||
// Auto-prune runs every minute to automatically delete oldest messages
|
||||
// if total is greater than the threshold
|
||||
func pruneCron() {
|
||||
for {
|
||||
// time.Sleep(5 * 60 * time.Second)
|
||||
time.Sleep(60 * time.Second)
|
||||
mailboxes, err := db.ListCollections()
|
||||
if err != nil {
|
||||
@@ -84,7 +83,7 @@ func pruneCron() {
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
|
||||
statsRefresh(m)
|
||||
_ = statsRefresh(m)
|
||||
if !strings.HasSuffix(m, "_data") {
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
@@ -92,3 +91,11 @@ func pruneCron() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeMailboxName returns a clean mailbox name
|
||||
// allowing only `alphanumeric` characters and `-“
|
||||
func sanitizeMailboxName(mailbox string) string {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
|
||||
|
||||
return re.ReplaceAllString(mailbox, "")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
@@ -184,6 +185,10 @@ func extract(filePath string, directory string) error {
|
||||
}
|
||||
|
||||
fileInfo := header.FileInfo()
|
||||
// paths could contain a '..', is used in a file system operations
|
||||
if strings.Contains(fileInfo.Name(), "..") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(directory, filepath.Dir(header.Name))
|
||||
filename := filepath.Join(dir, fileInfo.Name())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user