package api import ( "bufio" "context" "encoding/json" "fmt" "log/slog" "math" "net/http" "os" "os/exec" "runtime" "strconv" "strings" "sync" "syscall" "time" "github.com/archivmail/config" "github.com/archivmail/internal/audit" "github.com/archivmail/internal/auth" imapstore "github.com/archivmail/internal/imap" "github.com/archivmail/internal/index" "github.com/archivmail/internal/smtpd" "github.com/archivmail/internal/storage" "github.com/archivmail/internal/userstore" "github.com/archivmail/pkg/mailparser" ) type contextKey string const sessionKey contextKey = "session" // Server is the archivmail HTTP API server. type Server struct { cfg config.APIConfig store *storage.Store idx index.Indexer authMgr *auth.Manager users *userstore.Store audlog *audit.Logger logger *slog.Logger mux *http.ServeMux smtpDaemon *smtpd.Daemon imapStore *imapstore.Store imapImporter *imapstore.Importer imapScheduler *imapstore.Scheduler uploadJobs sync.Map // jobID → *UploadJob } // SetSMTPDaemon wires the SMTP daemon into the API server after construction. func (s *Server) SetSMTPDaemon(d *smtpd.Daemon) { s.smtpDaemon = d } // SetImap wires the IMAP store, importer, and scheduler into the API server after construction. func (s *Server) SetImap(store *imapstore.Store, importer *imapstore.Importer, scheduler *imapstore.Scheduler) { s.imapStore = store s.imapImporter = importer s.imapScheduler = scheduler } // New creates and wires up a new API server. func New( cfg config.APIConfig, store *storage.Store, idx index.Indexer, authMgr *auth.Manager, users *userstore.Store, audlog *audit.Logger, logger *slog.Logger, ) *Server { s := &Server{ cfg: cfg, store: store, idx: idx, authMgr: authMgr, users: users, audlog: audlog, logger: logger, mux: http.NewServeMux(), } s.routes() return s } func (s *Server) routes() { s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.HandleFunc("POST /api/auth/login", s.handleLogin) s.mux.HandleFunc("GET /api/auth/me", s.authMiddleware(s.handleMe)) s.mux.HandleFunc("POST /api/auth/logout", s.authMiddleware(s.handleLogout)) s.mux.HandleFunc("GET /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListUsers))) s.mux.HandleFunc("POST /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleCreateUser))) s.mux.HandleFunc("PATCH /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpdateUser))) s.mux.HandleFunc("DELETE /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteUser))) s.mux.HandleFunc("GET /api/search", s.authMiddleware(s.handleSearch)) s.mux.HandleFunc("GET /api/audit", s.authMiddleware(s.requireRole(userstore.RoleAuditor, s.handleAuditLog))) s.mux.HandleFunc("GET /api/admin/smtp/status", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSMTPStatus))) s.mux.HandleFunc("GET /api/admin/storage/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleStorageStats))) s.mux.HandleFunc("GET /api/mails/{id}", s.authMiddleware(s.requireMailAccess(s.handleGetMail))) s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.authMiddleware(s.requireMailAccess(s.handleGetAttachment))) s.mux.HandleFunc("GET /api/mails/{id}/raw", s.authMiddleware(s.requireMailAccess(s.handleGetRaw))) s.mux.HandleFunc("GET /api/admin/services", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListServices))) s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleServiceAction))) s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats))) // Export routes s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF))) s.mux.HandleFunc("POST /api/export/zip", s.authMiddleware(s.requireMailAccess(s.handleExportZIP))) // Upload routes (admin only) s.mux.HandleFunc("POST /api/admin/upload", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpload))) s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadProgress))) // Upload routes (all authenticated users) s.mux.HandleFunc("POST /api/upload", s.authMiddleware(s.handleUpload)) s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.authMiddleware(s.handleUploadProgress)) // IMAP routes (accessible to all authenticated users) s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap)) s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap)) s.mux.HandleFunc("DELETE /api/imap/{id}", s.authMiddleware(s.handleDeleteImap)) s.mux.HandleFunc("PATCH /api/imap/{id}", s.authMiddleware(s.handleUpdateImapInterval)) s.mux.HandleFunc("POST /api/imap/test", s.authMiddleware(s.handleTestImap)) s.mux.HandleFunc("POST /api/imap/{id}/import", s.authMiddleware(s.handleStartImport)) s.mux.HandleFunc("GET /api/imap/{id}/progress", s.authMiddleware(s.handleImapProgress)) s.mux.HandleFunc("POST /api/imap/{id}/sync", s.authMiddleware(s.handleSyncNow)) } // ServeHTTP implements http.Handler. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } // --- handlers --- func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } const ( loginMaxFailures = 5 loginWindow = 15 * time.Minute ) func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } // Rate-limiting: block after too many recent failures failures, err := s.users.CountRecentFailures(req.Username, loginWindow) if err == nil && failures >= loginMaxFailures { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: req.Username, IPAddress: remoteIP(r), Success: false, Detail: "rate limited", }) writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later") return } token, user, err := s.authMgr.Login(req.Username, req.Password) if err != nil { _ = s.users.RecordLoginAttempt(req.Username, remoteIP(r)) s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: req.Username, IPAddress: remoteIP(r), Success: false, Detail: err.Error(), }) writeError(w, http.StatusUnauthorized, "invalid credentials") return } _ = s.users.UpdateLastLogin(user.ID) s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, IPAddress: remoteIP(r), Success: true, }) http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", MaxAge: 8 * 3600, HttpOnly: true, SameSite: http.SameSiteStrictMode, // Secure: true — enable when TLS is terminated at this server }) writeJSON(w, http.StatusOK, map[string]interface{}{ "user": map[string]interface{}{ "id": user.ID, "username": user.Username, "email": user.Email, "role": user.Role, }, }) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) user, err := s.users.GetByUsername(sess.Username) if err != nil { writeError(w, http.StatusInternalServerError, "user lookup failed") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "username": user.Username, "email": user.Email, "role": user.Role, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { // Read token from cookie first, then Bearer header token := "" if c, err := r.Cookie(sessionCookieName); err == nil { token = c.Value } if token == "" { token = extractBearerToken(r) } if token != "" { _ = s.authMgr.Logout(token) } // Clear the session cookie http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventLogout, Username: sess.Username, IPAddress: remoteIP(r), Success: true, }) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { users, err := s.users.List("") if err != nil { writeError(w, http.StatusInternalServerError, "failed to list users") return } type userResp struct { ID int64 `json:"id"` Username string `json:"username"` Email string `json:"email"` Role string `json:"role"` Active bool `json:"active"` } resp := make([]userResp, 0, len(users)) for _, u := range users { resp = append(resp, userResp{ ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, Active: u.Active, }) } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` Email string `json:"email"` Password string `json:"password"` Role string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } user, err := s.users.Create(userstore.CreateUserRequest{ Username: req.Username, Email: req.Email, Password: req.Password, Role: req.Role, }) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, IPAddress: remoteIP(r), Detail: "created user: " + user.Username, Success: true, }) writeJSON(w, http.StatusCreated, map[string]interface{}{ "id": user.ID, "username": user.Username, "email": user.Email, "role": user.Role, "active": user.Active, }) } func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid user id") return } var req struct { Email *string `json:"email"` Role *string `json:"role"` Active *bool `json:"active"` Password *string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } updated, err := s.users.Update(id, userstore.UpdateUserRequest{ Email: req.Email, Role: req.Role, Active: req.Active, Password: req.Password, }) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, IPAddress: remoteIP(r), Detail: fmt.Sprintf("updated user %d", id), Success: true, }) writeJSON(w, http.StatusOK, map[string]interface{}{ "id": updated.ID, "username": updated.Username, "email": updated.Email, "role": updated.Role, "active": updated.Active, }) } func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid user id") return } if err := s.users.DeleteSafe(id); err != nil { if err.Error() == "userstore: cannot delete last admin" { writeError(w, http.StatusConflict, "cannot delete the last active admin") return } writeError(w, http.StatusNotFound, err.Error()) return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, IPAddress: remoteIP(r), Detail: fmt.Sprintf("deleted user %d", id), Success: true, }) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") fromFilter := r.URL.Query().Get("from") toFilter := r.URL.Query().Get("to") dateFromStr := r.URL.Query().Get("date_from") dateToStr := r.URL.Query().Get("date_to") sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc" hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false" pageStr := r.URL.Query().Get("page") pageSizeStr := r.URL.Query().Get("page_size") page, _ := strconv.Atoi(pageStr) pageSize, _ := strconv.Atoi(pageSizeStr) if pageSize <= 0 { pageSize = 25 } req := index.SearchRequest{ Query: q, Sort: sortParam, PageSize: pageSize, Page: page, } if hasAttachStr == "true" { v := true req.HasAttachment = &v } else if hasAttachStr == "false" { v := false req.HasAttachment = &v } // Domain search: @domain.de matches both From AND To fields. // A value starting with '@' triggers OR-search across XF and XT prefixes. if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") { domain := fromFilter if domain == "" { domain = toFilter } req.OwnEmail = domain } else { req.From = fromFilter req.To = toFilter } if dateFromStr != "" { if t, err := time.Parse(time.RFC3339, dateFromStr); err == nil { req.DateFrom = &t } else if t, err := time.Parse(time.DateOnly, dateFromStr); err == nil { req.DateFrom = &t } } if dateToStr != "" { if t, err := time.Parse(time.RFC3339, dateToStr); err == nil { req.DateTo = &t } else if t, err := time.Parse(time.DateOnly, dateToStr); err == nil { // end of day for date_to t = t.Add(24*time.Hour - time.Second) req.DateTo = &t } } result, err := s.idx.Search(req) if err != nil { writeError(w, http.StatusInternalServerError, "search failed") return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventSearch, Username: sess.Username, IPAddress: remoteIP(r), Query: q, Success: true, }) // Enrich hits with metadata (from, subject, date, size, attachments). type enrichedHit struct { ID string `json:"id"` Score float64 `json:"score"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` Subject string `json:"subject,omitempty"` Date string `json:"date,omitempty"` Size int64 `json:"size,omitempty"` HasAttachments bool `json:"has_attachments"` } enriched := make([]enrichedHit, 0, len(result.Hits)) for _, h := range result.Hits { eh := enrichedHit{ID: h.ID, Score: h.Score} if raw, err := s.store.Load(h.ID); err == nil { eh.Size = int64(len(raw)) if pm, err := mailparser.Parse(raw); err == nil { eh.From = pm.From if len(pm.To) > 0 { eh.To = strings.Join(pm.To, ", ") } eh.Subject = pm.Subject if !pm.Date.IsZero() { eh.Date = pm.Date.UTC().Format(time.RFC3339) } eh.HasAttachments = len(pm.Attachments) > 0 } } enriched = append(enriched, eh) } writeJSON(w, http.StatusOK, map[string]interface{}{ "total": result.Total, "hits": enriched, }) } func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) { if s.smtpDaemon == nil { writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false}) return } writeJSON(w, http.StatusOK, s.smtpDaemon.Status()) } func (s *Server) handleStorageStats(w http.ResponseWriter, r *http.Request) { stats, err := s.store.Stats() if err != nil { writeError(w, http.StatusInternalServerError, "failed to read storage stats") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "total_mails": stats.TotalMails, "total_bytes": stats.TotalBytes, }) } func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) { pageStr := r.URL.Query().Get("page") pageSizeStr := r.URL.Query().Get("page_size") username := r.URL.Query().Get("username") eventType := r.URL.Query().Get("event_type") page, _ := strconv.Atoi(pageStr) pageSize, _ := strconv.Atoi(pageSizeStr) if pageSize <= 0 { pageSize = 50 } entries, total, err := s.audlog.Query(audit.QueryFilter{ Username: username, EventType: eventType, PageSize: pageSize, Page: page, }) if err != nil { writeError(w, http.StatusInternalServerError, "audit query failed") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "total": total, "entries": entries, }) } // --- middleware --- const sessionCookieName = "archivmail_session" func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Prefer httpOnly cookie; fall back to Bearer token for CLI/API clients. token := "" if c, err := r.Cookie(sessionCookieName); err == nil { token = c.Value } if token == "" { token = extractBearerToken(r) } if token == "" { writeError(w, http.StatusUnauthorized, "missing authorization") return } sess, err := s.authMgr.ValidateToken(token) if err != nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return } ctx := context.WithValue(r.Context(), sessionKey, sess) next(w, r.WithContext(ctx)) } } func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess == nil || !auth.HasRole(sess.Role, role) { writeError(w, http.StatusForbidden, "insufficient permissions") return } next(w, r) } } // --- helpers --- func writeJSON(w http.ResponseWriter, code int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) } func extractBearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { return strings.TrimPrefix(h, "Bearer ") } return "" } func sessionFromCtx(ctx context.Context) *auth.Session { v := ctx.Value(sessionKey) if v == nil { return &auth.Session{} } if s, ok := v.(*auth.Session); ok { return s } return &auth.Session{} } func remoteIP(r *http.Request) string { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { return strings.Split(fwd, ",")[0] } return r.RemoteAddr } // ── Mail access middleware ──────────────────────────────────────────────── // requireMailAccess blocks admin role (no mail access) and passes user/auditor through. func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.Role == userstore.RoleAdmin { writeError(w, http.StatusForbidden, "admins have no access to mail content") return } next(w, r) } } // ── Mail handlers ───────────────────────────────────────────────────────── func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") raw, err := s.store.Load(id) if err != nil { writeError(w, http.StatusNotFound, "mail not found") return } pm, err := mailparser.Parse(raw) if err != nil { writeError(w, http.StatusInternalServerError, "failed to parse mail") return } // user role: only own mailbox sess := sessionFromCtx(r.Context()) if sess.Role == userstore.RoleUser { u, err := s.users.GetByUsername(sess.Username) if err != nil || !mailBelongsToUser(pm, u.Email) { writeError(w, http.StatusForbidden, "access denied") return } } type attachMeta struct { Index int `json:"index"` Filename string `json:"filename"` ContentType string `json:"content_type"` Size int `json:"size"` } attachments := make([]attachMeta, len(pm.Attachments)) for i, a := range pm.Attachments { attachments[i] = attachMeta{ Index: i, Filename: a.Filename, ContentType: a.ContentType, Size: a.Size, } } var dateStr string if !pm.Date.IsZero() { dateStr = pm.Date.UTC().Format(time.RFC3339) } // Verify status vs, _ := s.store.GetVerifyStatus(r.Context(), id) var verifyOK interface{} = nil var verifiedAt interface{} = nil if vs.VerifyOK != nil { verifyOK = *vs.VerifyOK } if vs.VerifiedAt != nil { verifiedAt = vs.VerifiedAt.UTC().Format(time.RFC3339) } writeJSON(w, http.StatusOK, map[string]interface{}{ "id": id, "from": pm.From, "to": strings.Join(pm.To, ", "), "cc": strings.Join(pm.CC, ", "), "subject": pm.Subject, "date": dateStr, "size": len(raw), "body_html": pm.HTMLBody, "body_plain": pm.TextBody, "raw_headers": extractRawHeaders(raw), "attachments": attachments, "verify_ok": verifyOK, "verified_at": verifiedAt, }) } func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") indexStr := r.PathValue("index") idx, err := strconv.Atoi(indexStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid attachment index") return } raw, err := s.store.Load(id) if err != nil { writeError(w, http.StatusNotFound, "mail not found") return } pm, err := mailparser.Parse(raw) if err != nil { writeError(w, http.StatusInternalServerError, "failed to parse mail") return } sess := sessionFromCtx(r.Context()) if sess.Role == userstore.RoleUser { u, err := s.users.GetByUsername(sess.Username) if err != nil || !mailBelongsToUser(pm, u.Email) { writeError(w, http.StatusForbidden, "access denied") return } } if idx < 0 || idx >= len(pm.Attachments) { writeError(w, http.StatusNotFound, "attachment not found") return } a := pm.Attachments[idx] filename := a.Filename if filename == "" { filename = fmt.Sprintf("attachment-%d", idx) } w.Header().Set("Content-Type", a.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) w.Header().Set("Content-Length", strconv.Itoa(len(a.Data))) w.WriteHeader(http.StatusOK) w.Write(a.Data) } func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") raw, err := s.store.Load(id) if err != nil { writeError(w, http.StatusNotFound, "mail not found") return } // Access check for user role sess := sessionFromCtx(r.Context()) if sess.Role == userstore.RoleUser { pm, err := mailparser.Parse(raw) if err == nil { u, err := s.users.GetByUsername(sess.Username) if err != nil || !mailBelongsToUser(pm, u.Email) { writeError(w, http.StatusForbidden, "access denied") return } } } w.Header().Set("Content-Type", "message/rfc822") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.eml"`, id[:16])) w.Header().Set("Content-Length", strconv.Itoa(len(raw))) w.WriteHeader(http.StatusOK) w.Write(raw) } // ── Helpers ─────────────────────────────────────────────────────────────── // mailBelongsToUser checks if the user's email appears in To or CC. func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool { email := strings.ToLower(userEmail) for _, to := range pm.To { if strings.ToLower(to) == email { return true } } for _, cc := range pm.CC { if strings.ToLower(cc) == email { return true } } return false } // extractRawHeaders returns the header section of a raw RFC 2822 email. func extractRawHeaders(raw []byte) string { for i := 0; i < len(raw)-3; i++ { if raw[i] == '\r' && raw[i+1] == '\n' && raw[i+2] == '\r' && raw[i+3] == '\n' { return string(raw[:i]) } if raw[i] == '\n' && raw[i+1] == '\n' { return string(raw[:i]) } } return string(raw) } // --- Service management --- // allowedServices is the whitelist of systemd service names the admin may control. var allowedServices = []string{ "archivmail", "archivmail-web", "postgresql@17-main", "postfix", "nginx", } type ServiceStatus struct { Name string `json:"name"` DisplayName string `json:"display_name"` Active string `json:"active"` // active, inactive, failed, unknown Sub string `json:"sub"` // running, dead, exited, ... Enabled string `json:"enabled"` // enabled, disabled, static, unknown Description string `json:"description"` ExternalBlocked *bool `json:"external_blocked,omitempty"` // only set for archivmail } func isAllowedService(name string) bool { for _, s := range allowedServices { if s == name { return true } } return false } func systemctlShow(name string) ServiceStatus { svc := ServiceStatus{Name: name, DisplayName: name} out, err := exec.Command("systemctl", "show", name+".service", "--property=ActiveState,SubState,UnitFileState,Description", "--no-pager").Output() if err != nil { svc.Active = "unknown" svc.Sub = "" svc.Enabled = "unknown" } else { for _, line := range strings.Split(string(out), "\n") { k, v, ok := strings.Cut(line, "=") if !ok { continue } switch k { case "ActiveState": svc.Active = v case "SubState": svc.Sub = v case "UnitFileState": svc.Enabled = v case "Description": svc.Description = v } } } if name == "archivmail" { blocked := nftAPIBlocked() svc.ExternalBlocked = &blocked } return svc } // nftAPIBlocked reports whether external access to port 8080 is currently blocked. func nftAPIBlocked() bool { out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", "status").Output() if err != nil { return false } return strings.TrimSpace(string(out)) == "blocked" } func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) { result := make([]ServiceStatus, 0, len(allowedServices)) for _, name := range allowedServices { result = append(result, systemctlShow(name)) } writeJSON(w, http.StatusOK, result) } func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !isAllowedService(name) { writeError(w, http.StatusBadRequest, "unknown service") return } var body struct { Action string `json:"action"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid request") return } allowedActions := map[string]bool{ "start": true, "stop": true, "restart": true, "enable": true, "disable": true, } nftActions := map[string]string{ "block_external": "block", "allow_external": "unblock", } if nftArg, isNft := nftActions[body.Action]; isNft { if name != "archivmail" { writeError(w, http.StatusBadRequest, "external access control only available for archivmail") return } out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", nftArg).CombinedOutput() if err != nil { writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out))) return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "service." + body.Action, Username: sess.Username, IPAddress: remoteIP(r), Detail: name, Success: true, }) writeJSON(w, http.StatusOK, systemctlShow(name)) return } if !allowedActions[body.Action] { writeError(w, http.StatusBadRequest, "unknown action") return } out, err := exec.Command("sudo", "/usr/bin/systemctl", body.Action, name+".service").CombinedOutput() if err != nil { writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out))) return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "service." + body.Action, Username: sess.Username, IPAddress: remoteIP(r), Detail: name, Success: true, }) writeJSON(w, http.StatusOK, systemctlShow(name)) } // ── IMAP handlers ───────────────────────────────────────────────────────── func (s *Server) handleListImap(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") return } sess := sessionFromCtx(r.Context()) isAdmin := sess.Role == userstore.RoleAdmin accounts, err := s.imapStore.List(r.Context(), sess.Username, isAdmin) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list IMAP accounts") return } if accounts == nil { accounts = []imapstore.Account{} } writeJSON(w, http.StatusOK, accounts) } func (s *Server) handleCreateImap(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") return } var req struct { Name string `json:"name"` Host string `json:"host"` Port int `json:"port"` TLS string `json:"tls"` Username string `json:"username"` Password string `json:"password"` ExcludedFolders []string `json:"excluded_folders"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Name == "" || req.Host == "" || req.Username == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "name, host, username and password are required") return } if req.Port <= 0 { req.Port = 993 } if req.TLS == "" { req.TLS = "ssl" } if req.ExcludedFolders == nil { req.ExcludedFolders = []string{} } sess := sessionFromCtx(r.Context()) acc := imapstore.Account{ Owner: sess.Username, Name: req.Name, Host: req.Host, Port: req.Port, TLS: req.TLS, Username: req.Username, ExcludedFolders: req.ExcludedFolders, } created, err := s.imapStore.Create(r.Context(), acc, req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create IMAP account") return } writeJSON(w, http.StatusCreated, created) } func (s *Server) handleDeleteImap(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") return } idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } acc, err := s.imapStore.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { writeError(w, http.StatusForbidden, "access denied") return } if err := s.imapStore.Delete(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete account") return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleTestImap(w http.ResponseWriter, r *http.Request) { var req struct { Host string `json:"host"` Port int `json:"port"` TLS string `json:"tls"` Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Host == "" || req.Username == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "host, username and password are required") return } if req.Port <= 0 { req.Port = 993 } if req.TLS == "" { req.TLS = "ssl" } c, err := imapstore.Connect(req.Host, req.Port, req.TLS) if err != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": false, "error": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err), }) return } defer c.Close() if err := c.Login(req.Username, req.Password).Wait(); err != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": false, "error": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err), }) return } folders, err := imapstore.ListFolders(c) if err != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": false, "error": fmt.Sprintf("Ordner konnten nicht gelesen werden: %v", err), }) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "folders": folders, }) } func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil || s.imapImporter == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") return } idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } acc, err := s.imapStore.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { writeError(w, http.StatusForbidden, "access denied") return } if acc.Status == "running" { writeError(w, http.StatusConflict, "import already running") return } go s.imapImporter.Run(context.Background(), id) // Return current account state (status will switch to "running" shortly) acc.Status = "running" writeJSON(w, http.StatusOK, acc) } func (s *Server) handleImapProgress(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") return } idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } acc, err := s.imapStore.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { writeError(w, http.StatusForbidden, "access denied") return } writeJSON(w, http.StatusOK, acc) } // handleSyncNow triggers an immediate incremental sync for a single IMAP account. func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil || s.imapScheduler == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") return } idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } acc, err := s.imapStore.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { writeError(w, http.StatusForbidden, "access denied") return } if err := s.imapScheduler.TriggerSync(r.Context(), id); err != nil { writeError(w, http.StatusConflict, err.Error()) return } // Return the account with the updated sync_running flag reflected immediately. acc.SyncRunning = true writeJSON(w, http.StatusOK, acc) } // handleUpdateImapInterval updates the automatic sync interval for an IMAP account. func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") return } idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } acc, err := s.imapStore.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { writeError(w, http.StatusForbidden, "access denied") return } var req struct { SyncIntervalMin int `json:"sync_interval_min"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } // 0 = disabled; otherwise must be between 5 and 1440 minutes. if req.SyncIntervalMin != 0 && (req.SyncIntervalMin < 5 || req.SyncIntervalMin > 1440) { writeError(w, http.StatusBadRequest, "sync_interval_min must be 0 (disabled) or between 5 and 1440") return } if err := s.imapStore.UpdateSyncInterval(r.Context(), id, req.SyncIntervalMin); err != nil { writeError(w, http.StatusInternalServerError, "failed to update sync interval") return } acc.SyncIntervalMin = req.SyncIntervalMin writeJSON(w, http.StatusOK, acc) } // ── System stats handler ───────────────────────────────────────────────── type diskStat struct { Mount string `json:"mount"` TotalBytes uint64 `json:"total_bytes"` UsedBytes uint64 `json:"used_bytes"` FreeBytes uint64 `json:"free_bytes"` UsedPct float64 `json:"used_pct"` FSType string `json:"fstype"` } type mailInfo struct { ID string `json:"id"` Date string `json:"date"` From string `json:"from"` Subject string `json:"subject"` } var excludedFSTypes = map[string]bool{ "tmpfs": true, "proc": true, "sysfs": true, "devtmpfs": true, "cgroup": true, "cgroup2": true, "overlay": true, "squashfs": true, "debugfs": true, "tracefs": true, "securityfs": true, "pstore": true, "efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true, "ramfs": true, "devpts": true, "fusectl": true, "configfs": true, "autofs": true, "nsfs": true, "rpc_pipefs": true, "fuse.lxcfs": true, "fuse": true, } func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) { // CPU: /proc/loadavg cpuResp := map[string]interface{}{"load1": 0.0, "load5": 0.0, "load15": 0.0, "num_cpu": runtime.NumCPU()} if data, err := os.ReadFile("/proc/loadavg"); err == nil { parts := strings.Fields(string(data)) if len(parts) >= 3 { l1, _ := strconv.ParseFloat(parts[0], 64) l5, _ := strconv.ParseFloat(parts[1], 64) l15, _ := strconv.ParseFloat(parts[2], 64) cpuResp = map[string]interface{}{"load1": l1, "load5": l5, "load15": l15, "num_cpu": runtime.NumCPU()} } } // RAM: /proc/meminfo ramResp := map[string]interface{}{"total_bytes": uint64(0), "used_bytes": uint64(0), "free_bytes": uint64(0), "used_pct": 0.0} if data, err := os.ReadFile("/proc/meminfo"); err == nil { kv := parseMeminfo(string(data)) total := kv["MemTotal"] * 1024 available := kv["MemAvailable"] * 1024 used := total - available var usedPct float64 if total > 0 { usedPct = math.Round(float64(used)/float64(total)*1000) / 10 } ramResp = map[string]interface{}{ "total_bytes": total, "used_bytes": used, "free_bytes": available, "used_pct": usedPct, } } // Disks: /proc/mounts + syscall.Statfs var disks []diskStat seenMounts := map[string]bool{} // deduplicate by mountpoint seenDevices := map[string]bool{} // deduplicate by device (catches ZFS bind-mounts) if data, err := os.ReadFile("/proc/mounts"); err == nil { scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 3 { continue } device := fields[0] mount := fields[1] fstype := fields[2] if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] { continue } seenMounts[mount] = true var stat syscall.Statfs_t if err := syscall.Statfs(mount, &stat); err != nil { continue } total := stat.Blocks * uint64(stat.Bsize) free := stat.Bavail * uint64(stat.Bsize) used := total - free if total == 0 { continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays) } seenDevices[device] = true var usedPct float64 if total > 0 { usedPct = math.Round(float64(used)/float64(total)*1000) / 10 } disks = append(disks, diskStat{ Mount: mount, TotalBytes: total, UsedBytes: used, FreeBytes: free, UsedPct: usedPct, FSType: fstype, }) } } if disks == nil { disks = []diskStat{} } // Archive: first & last mail archiveResp := map[string]interface{}{"first_mail": nil, "last_mail": nil} first, last, err := s.store.FirstAndLastMail() if err == nil { if first != nil { archiveResp["first_mail"] = mailRefToInfo(s.store, first) } if last != nil { archiveResp["last_mail"] = mailRefToInfo(s.store, last) } } writeJSON(w, http.StatusOK, map[string]interface{}{ "cpu": cpuResp, "ram": ramResp, "disks": disks, "archive": archiveResp, }) } func parseMeminfo(content string) map[string]uint64 { result := make(map[string]uint64) scanner := bufio.NewScanner(strings.NewReader(content)) for scanner.Scan() { k, v, ok := strings.Cut(scanner.Text(), ":") if !ok { continue } fields := strings.Fields(strings.TrimSpace(v)) if len(fields) == 0 { continue } val, err := strconv.ParseUint(fields[0], 10, 64) if err == nil { result[k] = val } } return result } func mailRefToInfo(store *storage.Store, ref *storage.MailRef) *mailInfo { dateStr := ref.ModTime.UTC().Format(time.RFC3339) raw, err := store.Load(ref.ID) if err != nil { return &mailInfo{ID: ref.ID, Date: dateStr} } pm, err := mailparser.Parse(raw) if err != nil { return &mailInfo{ID: ref.ID, Date: dateStr} } if !pm.Date.IsZero() { dateStr = pm.Date.UTC().Format(time.RFC3339) } return &mailInfo{ ID: ref.ID, Date: dateStr, From: pm.From, Subject: pm.Subject, } }