mirror of
https://github.com/axllent/mailpit.git
synced 2025-01-18 03:22:06 +02:00
Merge branch 'release/0.1.2'
This commit is contained in:
commit
8affa0f375
17
CHANGELOG.md
17
CHANGELOG.md
@ -3,6 +3,23 @@
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
|
||||
## 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
|
||||
|
@ -15,6 +15,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 +26,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.
|
||||
|
@ -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,7 @@ func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes
|
||||
|
@ -64,7 +64,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 +156,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
|
||||
|
@ -21,7 +21,9 @@ export default {
|
||||
searching: false,
|
||||
isConnected: false,
|
||||
scrollInPlace: false,
|
||||
message: false
|
||||
message: false,
|
||||
notificationsSupported: false,
|
||||
notificationsEnabled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -47,6 +49,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();
|
||||
},
|
||||
@ -214,6 +220,7 @@ export default {
|
||||
}
|
||||
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,6 +259,41 @@ 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>
|
||||
@ -315,7 +357,7 @@ 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">
|
||||
<ul class="list-unstyled mt-3 mb-5">
|
||||
<li v-if="isConnected" title="Messages will auto-load">
|
||||
<i class="bi bi-power text-success"></i>
|
||||
Connected
|
||||
@ -334,13 +376,19 @@ 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="mt-3">
|
||||
<a v-if="total" 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">
|
||||
<li class="mt-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 py-2">
|
||||
<i class="bi bi-github"></i>
|
||||
GitHub
|
||||
@ -400,11 +448,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 +466,27 @@ export default {
|
||||
</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>
|
||||
|
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"))
|
||||
}
|
||||
|
@ -140,40 +140,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)
|
||||
@ -220,7 +224,7 @@ func Store(mailbox string, b []byte) (string, error) {
|
||||
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 +258,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 +320,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 +348,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,6 +367,8 @@ 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) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
q, err := db.FindById(mailbox+"_data", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -440,6 +454,8 @@ 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) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
data, err := GetMessageRaw(mailbox, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -475,6 +491,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
|
||||
@ -491,6 +509,8 @@ func GetMessageRaw(mailbox, id string) ([]byte, error) {
|
||||
|
||||
// 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 +521,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 +541,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,7 +567,7 @@ 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)
|
||||
|
@ -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]
|
||||
|
@ -84,7 +84,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 +92,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())
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user