2026-08-12 12:56:22 +01:00
|
|
|
package webui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-08-14 13:04:55 +01:00
|
|
|
"time"
|
2026-08-12 12:56:22 +01:00
|
|
|
|
|
|
|
|
"mailgoserver/internal/db"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func emailDomain(addr string) string {
|
|
|
|
|
if i := strings.LastIndex(addr, "@"); i >= 0 {
|
|
|
|
|
return strings.ToLower(addr[i+1:])
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dashboard mirrors dashboard.py's dashboard(), scoped to the current admin's
|
|
|
|
|
// assigned domains unless they're a global admin.
|
|
|
|
|
func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
scope := scopeFromContext(r)
|
|
|
|
|
allowedNames, isGlobal, err := a.accessibleDomainNames(r)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("dashboard: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 21:14:19 +01:00
|
|
|
// Domain/sender/mailbox/DKIM counts are injected uniformly into every page by
|
|
|
|
|
// render() (see computeNavCounts) — only "near quota" is dashboard-specific,
|
|
|
|
|
// so it's the only mailbox stat still computed here.
|
|
|
|
|
var mailboxesNearQuota int
|
|
|
|
|
mailboxes, _ := a.DB.ListMailboxes()
|
|
|
|
|
for _, m := range mailboxes {
|
|
|
|
|
if !m.IsActive || (!isGlobal && !scope.Allowed(m.DomainID)) {
|
|
|
|
|
continue
|
2026-08-12 12:56:22 +01:00
|
|
|
}
|
2026-08-12 21:14:19 +01:00
|
|
|
if m.QuotaBytes > 0 && float64(m.UsedBytes)/float64(m.QuotaBytes)*100 >= 90 {
|
|
|
|
|
mailboxesNearQuota++
|
2026-08-12 12:56:22 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
allEmails, err := a.DB.ListEmailLogsPage(0, 50)
|
|
|
|
|
if err != nil {
|
|
|
|
|
setFlash(w, "error", "Error loading recent activity")
|
|
|
|
|
}
|
|
|
|
|
var recentEmails []db.EmailLog
|
|
|
|
|
for _, e := range allEmails {
|
|
|
|
|
if isGlobal || allowedNames[emailDomain(e.MailFrom)] {
|
|
|
|
|
recentEmails = append(recentEmails, e)
|
|
|
|
|
}
|
|
|
|
|
if len(recentEmails) == 10 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
allAuths, _ := a.DB.ListRecentAuthLogs(50)
|
|
|
|
|
var recentAuths []db.AuthLog
|
|
|
|
|
for _, au := range allAuths {
|
|
|
|
|
if isGlobal || allowedNames[authLogDomain(au.Identifier)] {
|
|
|
|
|
recentAuths = append(recentAuths, au)
|
|
|
|
|
}
|
|
|
|
|
if len(recentAuths) == 10 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
data := M{
|
2026-08-12 21:14:19 +01:00
|
|
|
"active": "dashboard",
|
|
|
|
|
"mailboxes_near_quota": mailboxesNearQuota,
|
|
|
|
|
"recent_emails": recentEmails,
|
|
|
|
|
"recent_auths": recentAuths,
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Attack-count tiles: blacklist entries aren't attributable to a single domain
|
|
|
|
|
// (see blacklist.go's doc comment), so this is global-admin-only, matching the
|
|
|
|
|
// Blacklist page and sidebar section's own gating.
|
|
|
|
|
if isGlobal {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
since24h := now.Add(-24 * time.Hour)
|
|
|
|
|
since7d := now.Add(-7 * 24 * time.Hour)
|
|
|
|
|
data["failed_auth_24h"], _ = a.DB.CountFailedAuthSince(since24h)
|
|
|
|
|
data["failed_auth_7d"], _ = a.DB.CountFailedAuthSince(since7d)
|
|
|
|
|
data["blacklist_events_24h"], _ = a.DB.CountBlacklistEventsSince(since24h)
|
|
|
|
|
data["blacklist_events_7d"], _ = a.DB.CountBlacklistEventsSince(since7d)
|
|
|
|
|
var activeBlacklistCount int
|
|
|
|
|
if entries, err := a.DB.ListBlacklist(); err == nil {
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
if e.ExpiresAt.After(now) {
|
|
|
|
|
activeBlacklistCount++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
data["active_blacklist_count"] = activeBlacklistCount
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.render(w, r, "dashboard.html", data)
|
2026-08-12 12:56:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// authLogDomain best-effort extracts a domain name from an AuthLog identifier, whose
|
|
|
|
|
// format varies by auth_type: a bare email ("sender"), "ip -> domain" (ip), or
|
|
|
|
|
// "sender@x -> target@y" (sender_validation). There's no domain_id column on this
|
|
|
|
|
// table (it predates admin scoping), so this is a text heuristic, not a foreign key.
|
|
|
|
|
func authLogDomain(identifier string) string {
|
|
|
|
|
if idx := strings.LastIndex(identifier, "->"); idx >= 0 {
|
|
|
|
|
return emailOrBareDomain(strings.TrimSpace(identifier[idx+2:]))
|
|
|
|
|
}
|
|
|
|
|
return emailOrBareDomain(identifier)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func emailOrBareDomain(s string) string {
|
|
|
|
|
if strings.Contains(s, "@") {
|
|
|
|
|
return emailDomain(s)
|
|
|
|
|
}
|
|
|
|
|
return strings.ToLower(s)
|
|
|
|
|
}
|