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 }