1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-01-22 03:39:59 +02:00

Merge branch 'feature/search-pagination' into develop

This commit is contained in:
Ralph Slooten 2023-07-12 17:24:35 +12:00
commit b8de57da27
10 changed files with 126 additions and 85 deletions

View File

@ -62,10 +62,11 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
res.Start = start res.Start = start
res.Messages = messages res.Messages = messages
res.Count = len(messages) res.Count = len(messages) // legacy - now undocumented in API specs
res.Total = stats.Total res.Total = stats.Total
res.Unread = stats.Unread res.Unread = stats.Unread
res.Tags = stats.Tags res.Tags = stats.Tags
res.MessagesCount = stats.Total
bytes, _ := json.Marshal(res) bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
@ -109,7 +110,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r) start, limit := getStartLimit(r)
messages, err := storage.Search(search, start, limit) messages, results, err := storage.Search(search, start, limit)
if err != nil { if err != nil {
httpError(w, err.Error()) httpError(w, err.Error())
return return
@ -121,8 +122,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
res.Start = start res.Start = start
res.Messages = messages res.Messages = messages
res.Count = len(messages) res.Count = results // legacy - now undocumented in API specs
res.Total = stats.Total res.Total = stats.Total
res.MessagesCount = results
res.Unread = stats.Unread res.Unread = stats.Unread
res.Tags = stats.Tags res.Tags = stats.Tags

View File

@ -12,9 +12,17 @@ type MessagesSummary struct {
// Total number of unread messages in mailbox // Total number of unread messages in mailbox
Unread int `json:"unread"` Unread int `json:"unread"`
// Number of results returned // Legacy - now undocumented in API specs but left for backwards compatibility.
// Removed from API documentation 2023-07-12
// swagger:ignore
Count int `json:"count"` Count int `json:"count"`
// Total number of messages matching current query
MessagesCount int `json:"messages_count"`
// // Number of results returned on current page
// Count int `json:"count"`
// Pagination offset // Pagination offset
Start int `json:"start"` Start int `json:"start"`

View File

@ -32,6 +32,7 @@ func WebUIConfig(w http.ResponseWriter, r *http.Request) {
// # Get web UI configuration // # Get web UI configuration
// //
// Returns configuration settings for the web UI. // Returns configuration settings for the web UI.
// Intended for web UI only!
// //
// Produces: // Produces:
// - application/json // - application/json

View File

@ -195,7 +195,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
return return
} }
assertEqual(t, count, m.Count, "wrong search results count") assertEqual(t, count, m.MessagesCount, "wrong search results count")
} }
func insertEmailData(t *testing.T) { func insertEmailData(t *testing.T) {

View File

@ -26,6 +26,7 @@ export default {
limit: 50, limit: 50,
total: 0, total: 0,
unread: 0, unread: 0,
messagesCount: 0,
start: 0, start: 0,
count: 0, count: 0,
tags: [], tags: [],
@ -75,7 +76,7 @@ export default {
return this.start > 0 return this.start > 0
}, },
canNext: function () { canNext: function () {
return this.total > (this.start + this.count) return this.messagesCount > (this.start + this.count)
}, },
unreadInSearch: function () { unreadInSearch: function () {
if (!this.searching) { if (!this.searching) {
@ -146,11 +147,12 @@ export default {
let uri = 'api/v1/messages' let uri = 'api/v1/messages'
if (self.search) { if (self.search) {
self.searching = true self.searching = true
self.items = []
uri = 'api/v1/search' uri = 'api/v1/search'
self.start = 0 // search is displayed on one page
params['query'] = self.search params['query'] = self.search
params['limit'] = 200 params['limit'] = self.limit
if (self.start > 0) {
params['start'] = self.start
}
} else { } else {
self.searching = false self.searching = false
params['limit'] = self.limit params['limit'] = self.limit
@ -162,7 +164,8 @@ export default {
self.get(uri, params, function (response) { self.get(uri, params, function (response) {
self.total = response.data.total self.total = response.data.total
self.unread = response.data.unread self.unread = response.data.unread
self.count = response.data.count self.count = response.data.messages.length
self.messagesCount = response.data.messages_count
self.start = response.data.start self.start = response.data.start
self.items = response.data.messages self.items = response.data.messages
self.tags = response.data.tags self.tags = response.data.tags
@ -194,6 +197,7 @@ export default {
doSearch: function (e) { doSearch: function (e) {
e.preventDefault() e.preventDefault()
this.start = 0
this.loadMessages() this.loadMessages()
}, },
@ -204,13 +208,14 @@ export default {
} }
this.search = 'tag:' + tag this.search = 'tag:' + tag
window.location.hash = "" window.location.hash = ""
this.start = 0
this.loadMessages() this.loadMessages()
}, },
resetSearch: function (e) { resetSearch: function (e) {
e.preventDefault() e.preventDefault()
this.search = '' this.search = ''
this.scrollInPlace = true this.start = 0
this.loadMessages() this.loadMessages()
}, },
@ -459,9 +464,9 @@ export default {
// websocket connect // websocket connect
connect: function () { connect: function () {
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws' let proto = location.protocol == 'https:' ? 'wss' : 'ws'
let ws = new WebSocket( let ws = new WebSocket(
wsproto + "://" + document.location.host + document.location.pathname + "api/events" proto + "://" + document.location.host + document.location.pathname + "api/events"
) )
let self = this let self = this
ws.onmessage = function (e) { ws.onmessage = function (e) {
@ -809,7 +814,7 @@ export default {
<i class="bi bi-check2-square"></i> <i class="bi bi-check2-square"></i>
</button> </button>
<select v-model="limit" v-on:change="loadMessages" v-if="!searching" <select v-model="limit" v-on:change="loadMessages"
class="form-select form-select-sm d-none d-md-inline w-auto me-2"> class="form-select form-select-sm d-none d-md-inline w-auto me-2">
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@ -817,23 +822,18 @@ export default {
<option value="200">200</option> <option value="200">200</option>
</select> </select>
<span v-if="searching"> <small>
<b>{{ formatNumber(items.length) }} result<template v-if="items.length != 1">s</template></b> {{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small>
</span> {{ formatNumber(messagesCount) }}
<span v-else> </small>
<small> <button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small> :title="'View previous ' + limit + ' messages'">
{{ formatNumber(total) }} <i class="bi bi-caret-left-fill"></i>
</small> </button>
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev" v-if="!searching" <button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
:title="'View previous ' + limit + ' messages'"> :title="'View next ' + limit + ' messages'">
<i class="bi bi-caret-left-fill"></i> <i class="bi bi-caret-right-fill"></i>
</button> </button>
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext" v-if="!searching"
:title="'View next ' + limit + ' messages'">
<i class="bi bi-caret-right-fill"></i>
</button>
</span>
</div> </div>
</div> </div>
<div class="row flex-fill" style="min-height:0"> <div class="row flex-fill" style="min-height:0">

View File

@ -23,11 +23,12 @@ export default {
return { return {
srcURI: false, srcURI: false,
iframes: [], // for resizing iframes: [], // for resizing
showTags: false, // to force rerendering of component showTags: false, // to force re-rendering of component
canSaveTags: false, // prevent auto-saving tags on render
messageTags: [], messageTags: [],
allTags: [], allTags: [],
loadHeaders: false, loadHeaders: false,
showMobileBtns: false, showMobileButtons: false,
scaleHTMLPreview: 'display', scaleHTMLPreview: 'display',
// keys names match bootstrap icon names // keys names match bootstrap icon names
responsiveSizes: { responsiveSizes: {
@ -39,20 +40,25 @@ export default {
}, },
watch: { watch: {
// handle changes to the URL messageID
message: { message: {
handler() { handler() {
let self = this let self = this
self.showTags = false self.showTags = false
self.canSaveTags = false
self.messageTags = self.message.Tags self.messageTags = self.message.Tags
self.allTags = self.existingTags self.allTags = self.existingTags
self.loadHeaders = false self.loadHeaders = false
self.scaleHTMLPreview = 'display';// default view self.scaleHTMLPreview = 'display' // default view
// delay to select first tab and add HTML highlighting (prev/next) // delay to select first tab and add HTML highlighting (prev/next)
self.$nextTick(function () { self.$nextTick(function () {
self.renderUI() self.renderUI()
self.showTags = true self.showTags = true
self.$nextTick(function () { self.$nextTick(function () {
Tags.init("select[multiple]") Tags.init("select[multiple]")
window.setTimeout(function () {
self.canSaveTags = true
}, 200)
}) })
}) })
}, },
@ -60,8 +66,8 @@ export default {
immediate: true immediate: true
}, },
messageTags() { messageTags() {
// save changed to tags // save changes to tags
if (this.showTags) { if (this.canSaveTags) {
this.saveTags() this.saveTags()
} }
}, },
@ -78,6 +84,7 @@ export default {
mounted() { mounted() {
let self = this let self = this
self.showTags = false self.showTags = false
self.canSaveTags = false
self.allTags = self.existingTags self.allTags = self.existingTags
window.addEventListener("resize", self.resizeIframes) window.addEventListener("resize", self.resizeIframes)
self.renderUI() self.renderUI()
@ -95,7 +102,12 @@ export default {
self.showTags = true self.showTags = true
self.$nextTick(function () { self.$nextTick(function () {
Tags.init("select[multiple]") self.$nextTick(function () {
Tags.init('select[multiple]')
window.setTimeout(function () {
self.canSaveTags = true
}, 200)
})
}) })
}, },
@ -104,6 +116,10 @@ export default {
}, },
methods: { methods: {
isHTMLTabSelected: function () {
this.showMobileButtons = this.$refs.navhtml
&& this.$refs.navhtml.classList.contains('active')
},
renderUI: function () { renderUI: function () {
let self = this let self = this
// click the first non-disabled tab // click the first non-disabled tab
@ -111,6 +127,14 @@ export default {
document.activeElement.blur() // blur focus document.activeElement.blur() // blur focus
document.getElementById('message-view').scrollTop = 0 document.getElementById('message-view').scrollTop = 0
self.isHTMLTabSelected()
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) {
listObj.addEventListener('shown.bs.tab', function (event) {
self.isHTMLTabSelected()
})
})
// delay 0.2s until vue has rendered the iframe content // delay 0.2s until vue has rendered the iframe content
window.setTimeout(function () { window.setTimeout(function () {
let p = document.getElementById('preview-html') let p = document.getElementById('preview-html')
@ -311,29 +335,30 @@ export default {
<nav> <nav>
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist"> <div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html" type="button" <button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html" type="button"
role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML" role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML" ref="navhtml"
v-on:click="showMobileBtns = true; resizeIframes()">HTML</button> v-on:click="resizeIframes()">HTML</button>
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab" data-bs-target="#nav-html-source" <button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab" data-bs-target="#nav-html-source"
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML" type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML">
v-on:click=" showMobileBtns = false">
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span> HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
</button> </button>
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text" <button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false" type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
:class="message.HTML == '' ? 'show' : ''" v-on:click=" showMobileBtns = false">Text</button> :class="message.HTML == '' ? 'show' : ''">
Text
</button>
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers" <button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
type="button" role="tab" aria-controls="nav-headers" aria-selected="false" type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
v-on:click=" showMobileBtns = false">
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span> <span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
</button> </button>
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button" <button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
role="tab" aria-controls="nav-raw" aria-selected="false" role="tab" aria-controls="nav-raw" aria-selected="false">
v-on:click=" showMobileBtns = false">Raw</button> Raw
</button>
<div class="d-none d-lg-block ms-auto me-2" v-if="showMobileBtns"> <div class="d-none d-lg-block ms-auto me-2" v-if="showMobileButtons">
<template v-for=" vals, key in responsiveSizes "> <template v-for="vals, key in responsiveSizes">
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'" <button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
v-on:click=" scaleHTMLPreview = key"> v-on:click="scaleHTMLPreview = key">
<i class="bi" :class="'bi-' + key"></i> <i class="bi" :class="'bi-' + key"></i>
</button> </button>
</template> </template>

View File

@ -489,7 +489,7 @@
}, },
"/api/v1/webui": { "/api/v1/webui": {
"get": { "get": {
"description": "Returns configuration settings for the web UI.", "description": "Returns configuration settings for the web UI.\nIntended for web UI only!",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -789,12 +789,6 @@
"description": "MessagesSummary is a summary of a list of messages", "description": "MessagesSummary is a summary of a list of messages",
"type": "object", "type": "object",
"properties": { "properties": {
"count": {
"description": "Number of results returned",
"type": "integer",
"format": "int64",
"x-go-name": "Count"
},
"messages": { "messages": {
"description": "Messages summary\nin:body", "description": "Messages summary\nin:body",
"type": "array", "type": "array",
@ -803,6 +797,12 @@
}, },
"x-go-name": "Messages" "x-go-name": "Messages"
}, },
"messages_count": {
"description": "Total number of messages matching current query",
"type": "integer",
"format": "int64",
"x-go-name": "MessagesCount"
},
"start": { "start": {
"description": "Pagination offset", "description": "Pagination offset",
"type": "integer", "type": "integer",
@ -926,10 +926,10 @@
}, },
"responses": { "responses": {
"BinaryResponse": { "BinaryResponse": {
"description": "Binary data reponse inherits the attachment's content type" "description": "Binary data response inherits the attachment's content type"
}, },
"ErrorResponse": { "ErrorResponse": {
"description": "Error reponse" "description": "Error response"
}, },
"InfoResponse": { "InfoResponse": {
"description": "Application information", "description": "Application information",
@ -949,7 +949,7 @@
} }
}, },
"OKResponse": { "OKResponse": {
"description": "Plain text \"ok\" reponse" "description": "Plain text \"ok\" response"
}, },
"TextResponse": { "TextResponse": {
"description": "Plain text response" "description": "Plain text response"

View File

@ -384,9 +384,14 @@ func List(start, limit int) ([]MessageSummary, error) {
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as: // 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> // is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!` // Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string, start, limit int) ([]MessageSummary, error) { func Search(search string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{} results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now() tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
s := strings.ToLower(search) s := strings.ToLower(search)
// add another quote if missing closing quote // add another quote if missing closing quote
@ -398,11 +403,11 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
p := shellwords.NewParser() p := shellwords.NewParser()
args, err := p.Parse(s) args, err := p.Parse(s)
if err != nil { if err != nil {
return results, errors.New("Your search contains invalid characters") return results, nrResults, errors.New("Your search contains invalid characters")
} }
// generate the SQL based on arguments // generate the SQL based on arguments
q := searchParser(args, start, limit) q := searchParser(args)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) { if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64 var created int64
@ -440,18 +445,29 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
em.Attachments = attachments em.Attachments = attachments
em.Read = read == 1 em.Read = read == 1
results = append(results, em) allResults = append(allResults, em)
}); err != nil { }); err != nil {
return results, err return results, nrResults, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
results = allResults[start:end]
} }
elapsed := time.Since(tsStart) elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed) logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
dbLastAction = time.Now() return results, nrResults, err
return results, err
} }
// GetMessage returns a Message generated from the mailbox_data collection. // GetMessage returns a Message generated from the mailbox_data collection.

View File

@ -168,9 +168,9 @@ func TestSearch(t *testing.T) {
for i := 1; i < 51; i++ { for i := 1; i < 51; i++ {
// search a random something that will return a single result // search a random something that will return a single result
searchIndx := rand.Intn(4) + 1 searchIdx := rand.Intn(4) + 1
var search string var search string
switch searchIndx { switch searchIdx {
case 1: case 1:
search = fmt.Sprintf("from-%d@example.com", i) search = fmt.Sprintf("from-%d@example.com", i)
case 2: case 2:
@ -181,7 +181,7 @@ func TestSearch(t *testing.T) {
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i) search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
} }
summaries, err := Search(search, 0, 100) summaries, _, err := Search(search, 0, 100)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
@ -196,8 +196,8 @@ func TestSearch(t *testing.T) {
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match") assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
} }
// search something that will return 200 rsults // search something that will return 200 results
summaries, err := Search("This is the email body", 0, testRuns) summaries, _, err := Search("This is the email body", 0, testRuns)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()

View File

@ -8,25 +8,14 @@ import (
) )
// SearchParser returns the SQL syntax for the database search based on the search arguments // SearchParser returns the SQL syntax for the database search based on the search arguments
func searchParser(args []string, start, limit int) *sqlf.Stmt { func searchParser(args []string) *sqlf.Stmt {
if limit == 0 {
limit = 50
}
q := sqlf.From("mailbox"). q := sqlf.From("mailbox").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags, Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON, IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON, IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON, IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON
`). `).OrderBy("Created DESC")
OrderBy("Created DESC").
Limit(limit).
Offset(start)
if limit > 0 {
q = q.Limit(limit)
}
for _, w := range args { for _, w := range args {
if cleanString(w) == "" { if cleanString(w) == "" {