1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-03-19 21:28:07 +02:00

Merge branch 'feature/multi-selection' into develop

This commit is contained in:
Ralph Slooten 2022-09-03 19:09:57 +12:00
commit 695270e515
7 changed files with 271 additions and 76 deletions

View File

@ -24,7 +24,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
- Optional browser notifications for new mail (HTTPS only) - Optional browser notifications for new mail (HTTPS only)
- Configurable automatic email pruning (default keeps the most recent 500 emails) - Configurable automatic email pruning (default keeps the most recent 500 emails)
- Email storage either in a temporary or persistent database ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage)) - Email storage either in a temporary or persistent database ([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 - Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size
- Can handle hundreds of thousands of emails - Can handle hundreds of thousands of emails
- Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication)) - Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
- Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS)) - Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS))

View File

@ -28,7 +28,8 @@ func apiMailboxStats(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(bytes) _, _ = w.Write(bytes)
} }
func apiListMailbox(w http.ResponseWriter, r *http.Request) { // List messages
func apiListMessages(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r) start, limit := getStartLimit(r)
messages, err := storage.List(start, limit) messages, err := storage.List(start, limit)
@ -52,17 +53,14 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes) _, _ = w.Write(bytes)
} }
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) { // Search all messages
func apiSearchMessages(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query")) search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" { if search == "" {
fourOFour(w) fourOFour(w)
return return
} }
// we will only return up to 200 results
start := 0
// limit := 200
messages, err := storage.Search(search) messages, err := storage.Search(search)
if err != nil { if err != nil {
httpError(w, err.Error()) httpError(w, err.Error())
@ -73,7 +71,7 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
var res messagesResult var res messagesResult
res.Start = start res.Start = 0
res.Items = messages res.Items = messages
res.Count = len(messages) res.Count = len(messages)
res.Total = stats.Total res.Total = stats.Total
@ -144,7 +142,7 @@ func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(data) _, _ = w.Write(data)
} }
// Delete all messages in the mailbox // Delete all messages
func apiDeleteAll(w http.ResponseWriter, r *http.Request) { func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
err := storage.DeleteAllMessages() err := storage.DeleteAllMessages()
if err != nil { if err != nil {
@ -156,6 +154,31 @@ func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
} }
// Delete all selected messages
func apiDeleteSelected(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.DeleteOneMessage(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Delete a single message // Delete a single message
func apiDeleteOne(w http.ResponseWriter, r *http.Request) { func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
@ -188,7 +211,7 @@ func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
} }
// Mark single message as unread // Mark all messages as read
func apiMarkAllRead(w http.ResponseWriter, r *http.Request) { func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
err := storage.MarkAllRead() err := storage.MarkAllRead()
if err != nil { if err != nil {
@ -200,6 +223,56 @@ func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
} }
// Mark selected message as read
func apiMarkSelectedRead(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark selected message as unread
func apiMarkSelectedUnread(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Websocket to broadcast changes // Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) { func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r) websockets.ServeWs(websockets.MessageHub, w, r)

View File

@ -34,17 +34,20 @@ func Listen() {
go websockets.MessageHub.Run() go websockets.MessageHub.Run()
r := mux.NewRouter() r := mux.NewRouter()
r.HandleFunc("/api/stats", middleWareFunc(apiMailboxStats)) r.HandleFunc("/api/stats", middleWareFunc(apiMailboxStats)).Methods("GET")
r.HandleFunc("/api/messages", middleWareFunc(apiListMailbox)) r.HandleFunc("/api/messages", middleWareFunc(apiListMessages)).Methods("GET")
r.HandleFunc("/api/search", middleWareFunc(apiSearchMailbox)) r.HandleFunc("/api/search", middleWareFunc(apiSearchMessages)).Methods("GET")
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteAll)) r.HandleFunc("/api/delete", middleWareFunc(apiDeleteAll)).Methods("GET")
r.HandleFunc("/api/events", apiWebsocket) r.HandleFunc("/api/delete", middleWareFunc(apiDeleteSelected)).Methods("POST")
r.HandleFunc("/api/read", apiMarkAllRead) r.HandleFunc("/api/events", apiWebsocket).Methods("GET")
r.HandleFunc("/api/{id}/source", middleWareFunc(apiDownloadSource)) r.HandleFunc("/api/read", apiMarkAllRead).Methods("GET")
r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)) r.HandleFunc("/api/read", apiMarkSelectedRead).Methods("POST")
r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne)) r.HandleFunc("/api/unread", apiMarkSelectedUnread).Methods("POST")
r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne)) r.HandleFunc("/api/{id}/source", middleWareFunc(apiDownloadSource)).Methods("GET")
r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage)) r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)).Methods("GET")
r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne)).Methods("GET")
r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne)).Methods("GET")
r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage)).Methods("GET")
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot)))) r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
http.Handle("/", r) http.Handle("/", r)

View File

@ -23,7 +23,8 @@ export default {
scrollInPlace: false, scrollInPlace: false,
message: false, message: false,
notificationsSupported: false, notificationsSupported: false,
notificationsEnabled: false notificationsEnabled: false,
selected: []
} }
}, },
watch: { watch: {
@ -60,6 +61,7 @@ export default {
loadMessages: function () { loadMessages: function () {
let self = this; let self = this;
let params = {}; let params = {};
this.selected = [];
let uri = 'api/messages'; let uri = 'api/messages';
if (self.search) { if (self.search) {
@ -128,10 +130,10 @@ export default {
openMessage: function(id) { openMessage: function(id) {
let self = this; let self = this;
let params = {}; self.selected = [];
let uri = 'api/' + self.currentPath let uri = 'api/' + self.currentPath
self.get(uri, params, function(response) { self.get(uri, false, 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) {
if (!self.items[i].Read) { if (!self.items[i].Read) {
@ -192,6 +194,19 @@ export default {
}); });
}, },
deleteSelected: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/delete'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markUnread: function() { markUnread: function() {
let self = this; let self = this;
if (!self.message) { if (!self.message) {
@ -215,6 +230,32 @@ export default {
}); });
}, },
markSelectedRead: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/read'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markSelectedUnread: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/unread'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// websocket connect // websocket connect
connect: function () { connect: function () {
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws'; let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
@ -270,7 +311,7 @@ export default {
return message.To[i].Address; return message.To[i].Address;
} }
return '[ Unknown ]'; return '[ Undisclosed recipients ]';
}, },
getRelativeCreated: function(message) { getRelativeCreated: function(message) {
@ -310,8 +351,48 @@ export default {
} }
}); });
} }
},
toggleSelected: function(e, id) {
e.preventDefault();
if (this.isSelected(id)) {
this.selected = this.selected.filter(function(ele){
return ele != id;
});
} else {
this.selected.push(id);
}
},
selectRange: function(e, id) {
e.preventDefault();
let selecting = false;
let lastSelected = this.selected.length > 0 && this.selected[this.selected.length - 1];
if (lastSelected === false) {
this.selected.push(id);
return;
} }
for (let d of this.items) {
if (selecting) {
this.selected.push(d.ID);
if (d.ID == lastSelected || d.ID == id) {
// reached backwards select
break;
}
} else if (d.ID == id || d.ID == lastSelected) {
this.selected.push(d.ID);
selecting = true;
}
}
},
isSelected: function(id) {
return this.selected.indexOf(id) != -1;
}
} }
} }
</script> </script>
@ -333,7 +414,7 @@ export default {
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span> <i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
</button> </button>
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread"> <button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
<i class="bi bi-envelope"></i> <span class="d-none d-md-inline">Mark unread</span> <i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
</button> </button>
<a :href="'api/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message"> <a :href="'api/' + 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> <i class="bi bi-file-arrow-down-fill"></i> <span class="d-none d-md-inline">Download</span>
@ -409,18 +490,42 @@ export default {
</span> </span>
</a> </a>
</li> </li>
<li class="my-3" v-if="unread"> <li class="my-3" v-if="unread && !selected.length">
<a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal"> <a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal">
<i class="bi bi-check2-square"></i> <i class="bi bi-eye-fill"></i>
Mark all read Mark all read
</a> </a>
</li> </li>
<li class="my-3" v-if="total"> <li class="my-3" v-if="total && !selected.length">
<a href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal"> <a href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal">
<i class="bi bi-trash-fill me-1 text-danger"></i> <i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all Delete all
</a> </a>
</li> </li>
<li class="my-3" v-if="selected.length > 0">
<b class="me-2">Selected {{selected.length}}</b>
<button class="btn btn-sm text-muted" v-on:click="selected=[]" title="Unselect messages"><i class="bi bi-x-circle"></i></button>
</li>
<li class="my-3 ms-2" v-if="unread && selected.length > 0">
<a href="#" v-on:click="markSelectedRead">
<i class="bi bi-eye-fill"></i>
Mark read
</a>
</li>
<li class="my-3 ms-2" v-if="selected.length > 0">
<a href="#" v-on:click="markSelectedUnread">
<i class="bi bi-eye-slash"></i>
Mark unread
</a>
</li>
<li class="my-3 ms-2" v-if="total && selected.length > 0">
<a href="#" v-on:click="deleteSelected">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete
</a>
</li>
<li class="my-3" v-if="notificationsSupported && !notificationsEnabled"> <li class="my-3" v-if="notificationsSupported && !notificationsEnabled">
<a href="#" data-bs-toggle="modal" data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"> <a href="#" data-bs-toggle="modal" data-bs-target="#EnableNotificationsModal" title="Enable browser notifications">
<i class="bi bi-bell"></i> <i class="bi bi-bell"></i>
@ -439,8 +544,10 @@ export default {
<div class="col-lg-10 col-md-9 mh-100 pe-0"> <div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page"> <div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page">
<div class="list-group" v-if="items.length"> <div class="list-group" v-if="items.length">
<a v-for="message in items" :href="'#'+message.ID" class="row message d-flex small list-group-item list-group-item-action" <a v-for="message in items" :href="'#'+message.ID"
:class="message.Read ? 'read':''" XXXv-on:click="openMessage(message)"> v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)"
class="row message d-flex small list-group-item list-group-item-action"
:class="message.Read ? 'read':'', isSelected(message.ID) ? 'selected':''">
<div class="col-lg-3"> <div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small"> <div class="d-lg-none float-end text-muted text-nowrap small">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i> <i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>

View File

@ -34,7 +34,7 @@
z-index: 1500; z-index: 1500;
} }
.message.read:not(.active) { .message.read:not(.active):not(.selected) {
color: $gray-500; color: $gray-500;
} }
@ -75,6 +75,21 @@
border-top: 0; border-top: 0;
} }
.message.selected {
background: $primary;
color: #fff;
.text-muted {
color: #fff !important;
}
&.read {
b {
font-weight: normal;
}
}
}
body.blur { body.blur {
.privacy { .privacy {
filter: blur(3px); filter: blur(3px);

View File

@ -96,12 +96,8 @@ const commonMixins = {
*/ */
post: function (url, values, callback) { post: function (url, values, callback) {
let self = this; let self = this;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
params.append(key, value);
}
self.loading++; self.loading++;
axios.post(url, params) axios.post(url, values)
.then(callback) .then(callback)
.catch(self.handleError) .catch(self.handleError)
.then(function () { .then(function () {

View File

@ -110,10 +110,11 @@ export default {
<tr class="small"> <tr class="small">
<th>To</th> <th>To</th>
<td class="privacy"> <td class="privacy">
<span v-for="(t, i) in message.To"> <span v-if="message.To" v-for="(t, i) in message.To">
<template v-if="i > 0">,</template> <template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }} {{ t.Name + " <" + t.Address +">" }}
</span> </span>
<span v-else>Undisclosed recipients</span>
</td> </td>
</tr> </tr>
<tr v-if="message.Cc" class="small"> <tr v-if="message.Cc" class="small">