mirror of
https://github.com/axllent/mailpit.git
synced 2025-08-13 20:04:49 +02:00
Feature: Add ability to mark all search results as read (#476)
This commit is contained in:
@@ -258,19 +258,6 @@ func DbSize() float64 {
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
// IsUnread returns whether a message is unread or not.
|
||||
func IsUnread(id string) bool {
|
||||
var unread int
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&unread).
|
||||
Where("Read = ?", 0).
|
||||
Where("ID = ?", id).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return unread == 1
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
@@ -362,7 +362,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
}
|
||||
|
||||
// mark message as read
|
||||
if err := MarkRead(id); err != nil {
|
||||
if err := MarkRead([]string{id}); err != nil {
|
||||
return &obj, err
|
||||
}
|
||||
|
||||
@@ -495,30 +495,28 @@ func LatestID(r *http.Request) (string, error) {
|
||||
}
|
||||
|
||||
// MarkRead will mark a message as read
|
||||
func MarkRead(id string) error {
|
||||
if !IsUnread(id) {
|
||||
return nil
|
||||
}
|
||||
func MarkRead(ids []string) error {
|
||||
for _, id := range ids {
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllRead will mark all messages as read
|
||||
@@ -572,32 +570,30 @@ func MarkAllUnread() error {
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(id string) error {
|
||||
if IsUnread(id) {
|
||||
return nil
|
||||
func MarkUnread(ids []string) error {
|
||||
for _, id := range ids {
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
}
|
||||
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessages deletes one or more messages in bulk
|
||||
|
@@ -100,6 +100,39 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
return results, nrResults, err
|
||||
}
|
||||
|
||||
// SearchUnreadCount returns the number of unread messages matching a search.
|
||||
// This is run one at a time to allow connected browsers to be updated.
|
||||
func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
|
||||
tsStart := time.Now()
|
||||
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var unread int64
|
||||
|
||||
q = q.Where("Read = 0").Select(`COUNT(*)`)
|
||||
|
||||
err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var ignore sql.NullString
|
||||
if err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", unread, search, elapsed)
|
||||
|
||||
return unread, err
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
@@ -224,6 +257,47 @@ func DeleteSearch(search, timezone string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSearchReadStatus marks all messages matching the search as read or unread
|
||||
func SetSearchReadStatus(search, timezone string, read bool) error {
|
||||
q := searchQueryBuilder(search, timezone).Where("Read = ?", !read)
|
||||
|
||||
ids := []string{}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
var ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if read {
|
||||
if err := MarkRead(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := MarkUnread(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchParser returns the SQL syntax for the database search based on the search arguments
|
||||
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
|
@@ -53,6 +53,9 @@ type MessagesSummary struct {
|
||||
// Total number of messages matching current query
|
||||
MessagesCount float64 `json:"messages_count"`
|
||||
|
||||
// Total number of unread messages matching current query
|
||||
MessagesUnreadCount float64 `json:"messages_unread"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
|
||||
@@ -100,6 +103,7 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
res.MessagesCount = stats.Total
|
||||
res.MessagesUnreadCount = stats.Unread
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
@@ -118,22 +122,36 @@ type setReadStatusParams struct {
|
||||
// example: true
|
||||
Read bool
|
||||
|
||||
// Array of message database IDs
|
||||
// Optional array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// default: []
|
||||
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
|
||||
IDs []string
|
||||
|
||||
// Optional messages matching a search
|
||||
//
|
||||
// required: false
|
||||
// example: tag:backups
|
||||
Search string
|
||||
}
|
||||
|
||||
// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type string
|
||||
TZ string `json:"tz"`
|
||||
}
|
||||
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
|
||||
// If no IDs are provided then all messages are updated.
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs.
|
||||
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/messages messages SetReadStatusParams
|
||||
//
|
||||
// # Set read status
|
||||
//
|
||||
// If no IDs are provided then all messages are updated.
|
||||
// You can optionally provide an array of IDs or a search string.
|
||||
// If neither IDs nor search is provided then all mailbox messages are updated.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
@@ -150,8 +168,9 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
var data struct {
|
||||
Read bool
|
||||
IDs []string
|
||||
Read bool
|
||||
IDs []string
|
||||
Search string
|
||||
}
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
@@ -161,8 +180,20 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ids := data.IDs
|
||||
search := data.Search
|
||||
|
||||
if len(ids) == 0 {
|
||||
if len(ids) > 0 && search != "" {
|
||||
httpError(w, "You may specify either IDs or a search query, not both")
|
||||
return
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
err := storage.SetSearchReadStatus(search, r.URL.Query().Get("tz"), data.Read)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else if len(ids) == 0 {
|
||||
if data.Read {
|
||||
err := storage.MarkAllRead()
|
||||
if err != nil {
|
||||
@@ -178,18 +209,14 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
if data.Read {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkRead(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := storage.MarkRead(ids); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkUnread(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := storage.MarkUnread(ids); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +285,7 @@ type searchParams struct {
|
||||
//
|
||||
// in: query
|
||||
// required: true
|
||||
// example: tag:backups
|
||||
// type: string
|
||||
Query string `json:"query"`
|
||||
|
||||
@@ -265,6 +293,7 @@ type searchParams struct {
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// default: 0
|
||||
// type integer
|
||||
Start string `json:"start"`
|
||||
|
||||
@@ -272,10 +301,11 @@ type searchParams struct {
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// default: 50
|
||||
// type integer
|
||||
Limit string `json:"limit"`
|
||||
|
||||
// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
@@ -326,6 +356,14 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
|
||||
unread, err := storage.SearchUnreadCount(search, r.URL.Query().Get("tz"), beforeTS)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res.MessagesUnreadCount = float64(unread)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
|
@@ -84,12 +84,12 @@ export default {
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.unread" @click="markAllRead">
|
||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
|
@@ -41,11 +41,32 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
this.delete(uri, false, (response) => {
|
||||
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
uri += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
}
|
||||
|
||||
this.delete(uri, false, () => {
|
||||
this.$router.push('/')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
const s = this.getSearch()
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
|
||||
let uri = this.resolve(`/api/v1/messages`)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
uri += '?tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
}
|
||||
|
||||
this.put(uri, { 'read': true, "search": s }, () => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -68,6 +89,16 @@ export default {
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
@click="deleteAllMessages" :disabled="!mailbox.count">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
@@ -86,6 +117,29 @@ export default {
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<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 search results 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(mailbox.messages_unread) }}
|
||||
message<span v-if="mailbox.messages_unread > 1">s</span>
|
||||
matching <code>{{ getSearch() }}</code>
|
||||
as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markAllRead">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
|
@@ -57,6 +57,7 @@ export default {
|
||||
mailbox.tags = response.data.tags // all tags
|
||||
mailbox.messages = response.data.messages // current messages
|
||||
mailbox.count = response.data.messages_count // total results for this mailbox/search
|
||||
mailbox.messages_unread = response.data.messages_unread // total unread results for this mailbox/search
|
||||
// ensure the pagination remains consistent
|
||||
pagination.start = response.data.start
|
||||
|
||||
|
@@ -548,7 +548,7 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "If no IDs are provided then all messages are updated.",
|
||||
"description": "You can optionally provide an array of IDs or a search string.\nIf neither IDs nor search is provided then all mailbox messages are updated.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -572,8 +572,9 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"IDs": {
|
||||
"description": "Array of message database IDs",
|
||||
"description": "Optional array of message database IDs",
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -587,9 +588,21 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"example": true
|
||||
},
|
||||
"Search": {
|
||||
"description": "Optional messages matching a search",
|
||||
"type": "string",
|
||||
"example": "tag:backups"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"x-go-name": "TZ",
|
||||
"description": "Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
|
||||
"name": "tz",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -669,6 +682,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"example": "tag:backups",
|
||||
"x-go-name": "Query",
|
||||
"description": "Search query",
|
||||
"name": "query",
|
||||
@@ -677,6 +691,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "0",
|
||||
"x-go-name": "Start",
|
||||
"description": "Pagination offset",
|
||||
"name": "start",
|
||||
@@ -684,6 +699,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "50",
|
||||
"x-go-name": "Limit",
|
||||
"description": "Limit results",
|
||||
"name": "limit",
|
||||
@@ -692,7 +708,7 @@
|
||||
{
|
||||
"type": "string",
|
||||
"x-go-name": "TZ",
|
||||
"description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
|
||||
"description": "Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
|
||||
"name": "tz",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -1624,6 +1640,12 @@
|
||||
"format": "double",
|
||||
"x-go-name": "MessagesCount"
|
||||
},
|
||||
"messages_unread": {
|
||||
"description": "Total number of unread messages matching current query",
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"x-go-name": "MessagesUnreadCount"
|
||||
},
|
||||
"start": {
|
||||
"description": "Pagination offset",
|
||||
"type": "integer",
|
||||
|
Reference in New Issue
Block a user