159 lines
5.4 KiB
Go
159 lines
5.4 KiB
Go
package webui
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
const perPage = 50
|
|
|
|
// logs mirrors logs.py's logs(): type=all|emails|auth, page (mostly cosmetic in "all"
|
|
// mode, matching the Python version's own quirk where "all" mode's pagination isn't
|
|
// real pagination — see the route inventory). Scoped admins get the fetched page
|
|
// filtered down to their domains in-memory (these tables have no domain_id column to
|
|
// filter in SQL — see accessibleDomainNames) — pagination counts stay based on the
|
|
// unfiltered page, same imprecision the "all" mode already had before scoping existed.
|
|
func (a *App) logs(w http.ResponseWriter, r *http.Request) {
|
|
allowedNames, isGlobal, err := a.accessibleDomainNames(r)
|
|
if err != nil {
|
|
setFlash(w, "error", "Error loading logs")
|
|
}
|
|
emailAllowed := func(e db.EmailLog) bool { return isGlobal || allowedNames[emailDomain(e.MailFrom)] }
|
|
// admin_login/admin_mfa entries are identified by admin username, not a mailbox
|
|
// email — there's no domain to attribute them to (a scoped admin's own username
|
|
// could otherwise coincidentally collide with a domain name they're allowed to
|
|
// see), so they're global-admin-only regardless of the identifier heuristic below.
|
|
authAllowed := func(au db.AuthLog) bool {
|
|
if au.AuthType == "admin_login" || au.AuthType == "admin_mfa" {
|
|
return isGlobal
|
|
}
|
|
return isGlobal || allowedNames[authLogDomain(au.Identifier)]
|
|
}
|
|
|
|
filterType := r.URL.Query().Get("type")
|
|
if filterType == "" {
|
|
filterType = "all"
|
|
}
|
|
page := atoi(r.URL.Query().Get("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
offset := (page - 1) * perPage
|
|
// Read once here (not just inside the "auth" case) so every branch's M literal
|
|
// can set it unconditionally — logs.html's pagination links reference it regardless
|
|
// of filter_type, and a map[string]any with the key entirely absent renders
|
|
// inconsistently across template functions versus one that's always present as "".
|
|
authCategory := r.URL.Query().Get("auth_category")
|
|
|
|
switch filterType {
|
|
case "emails":
|
|
fetched, err := a.DB.ListEmailLogsPage(offset, perPage)
|
|
if err != nil {
|
|
setFlash(w, "error", "Error loading logs")
|
|
}
|
|
var emails []db.EmailLog
|
|
for _, e := range fetched {
|
|
if emailAllowed(e) {
|
|
emails = append(emails, e)
|
|
}
|
|
}
|
|
recipientMap, attachMap := a.buildLogMaps(emails)
|
|
a.render(w, r, "logs.html", M{
|
|
"active": "logs", "logs": emails, "filter_type": filterType, "page": page,
|
|
"auth_category": authCategory,
|
|
"has_next": len(fetched) == perPage, "has_prev": page > 1,
|
|
"recipient_logs_map": recipientMap, "attachments_map": attachMap,
|
|
})
|
|
case "auth":
|
|
fetched, err := a.DB.ListAuthLogsPage(offset, perPage)
|
|
if err != nil {
|
|
setFlash(w, "error", "Error loading logs")
|
|
}
|
|
var auths []db.AuthLog
|
|
for _, au := range fetched {
|
|
if authAllowed(au) && authCategoryMatches(au.AuthType, authCategory) {
|
|
auths = append(auths, au)
|
|
}
|
|
}
|
|
a.render(w, r, "logs.html", M{
|
|
"active": "logs", "logs": auths, "filter_type": filterType, "page": page,
|
|
"auth_category": authCategory,
|
|
"has_next": len(fetched) == perPage, "has_prev": page > 1,
|
|
})
|
|
default:
|
|
half := perPage / 2
|
|
fetchedEmails, _ := a.DB.ListEmailLogsPage(0, half)
|
|
fetchedAuths, _ := a.DB.ListAuthLogsPage(0, half)
|
|
recipientMap, _ := a.buildLogMaps(fetchedEmails)
|
|
|
|
type combinedEntry struct {
|
|
M M
|
|
At time.Time
|
|
}
|
|
var combined []combinedEntry
|
|
for _, e := range fetchedEmails {
|
|
if !emailAllowed(e) {
|
|
continue
|
|
}
|
|
combined = append(combined, combinedEntry{M: M{"type": "email", "data": e, "recipients": recipientMap[e.ID]}, At: e.Timestamp})
|
|
}
|
|
for _, au := range fetchedAuths {
|
|
if !authAllowed(au) {
|
|
continue
|
|
}
|
|
combined = append(combined, combinedEntry{M: M{"type": "auth", "data": au}, At: au.CreatedAt})
|
|
}
|
|
sort.SliceStable(combined, func(i, j int) bool { return combined[i].At.After(combined[j].At) })
|
|
if len(combined) > perPage {
|
|
combined = combined[:perPage]
|
|
}
|
|
var logs []M
|
|
for _, c := range combined {
|
|
logs = append(logs, c.M)
|
|
}
|
|
a.render(w, r, "logs.html", M{
|
|
"active": "logs", "logs": logs, "filter_type": filterType, "page": page,
|
|
"auth_category": authCategory,
|
|
"has_next": len(logs) > perPage, "has_prev": page > 1,
|
|
})
|
|
}
|
|
}
|
|
|
|
// authCategoryMatches buckets esrv_auth_logs.auth_type values into "admin" (dashboard
|
|
// login/MFA), "webmail" (mailbox portal login/MFA), or "mailserver" (SMTP/IMAP — the
|
|
// same set abuseguard counts, see crud_ip_blacklist.go's smtpImapAuthTypesSQL). An
|
|
// empty category matches everything (no filter applied).
|
|
func authCategoryMatches(authType, category string) bool {
|
|
switch category {
|
|
case "", "all":
|
|
return true
|
|
case "admin":
|
|
return authType == "admin_login" || authType == "admin_mfa"
|
|
case "webmail":
|
|
return authType == "webmail_login" || authType == "mailbox_mfa"
|
|
case "mailserver":
|
|
switch authType {
|
|
case "sender", "mailbox", "sender_validation", "mailbox_validation", "ip", "imap_login":
|
|
return true
|
|
}
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (a *App) buildLogMaps(emails []db.EmailLog) (map[int64][]db.EmailRecipientLog, map[int64][]db.EmailAttachment) {
|
|
recipientMap := map[int64][]db.EmailRecipientLog{}
|
|
attachMap := map[int64][]db.EmailAttachment{}
|
|
for _, e := range emails {
|
|
recs, _ := a.DB.ListRecipientLogsForEmail(e.ID)
|
|
recipientMap[e.ID] = recs
|
|
atts, _ := a.DB.ListAttachmentsForEmail(e.ID)
|
|
attachMap[e.ID] = atts
|
|
}
|
|
return recipientMap, attachMap
|
|
}
|