package api import ( "context" "encoding/json" "fmt" "net/http" "strconv" "archivmail/internal/auth" imapstore "archivmail/internal/imap" pop3store "archivmail/internal/pop3" "archivmail/internal/userstore" ) // ── IMAP handlers ───────────────────────────────────────────────────────── // tenantAccessAllowed checks whether the given session may access an IMAP/POP3 // account belonging to accTenantID. Superadmins (sess.TenantID == nil) may // access any tenant. Other admins may only access accounts within their own // tenant (accTenantID must be set and match). func tenantAccessAllowed(sess *auth.Session, accTenantID *int64) bool { if sess.TenantID == nil { return true } return accTenantID != nil && *accTenantID == *sess.TenantID } 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()) // SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin). isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin) accounts, err := s.imapStore.List(r.Context(), sess.Username, isAdmin, sess.TenantID) 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, TenantID: sess.TenantID, } 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 && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if !tenantAccessAllowed(sess, acc.TenantID) { 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.Client) 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 && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if !tenantAccessAllowed(sess, acc.TenantID) { 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 && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if !tenantAccessAllowed(sess, acc.TenantID) { 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 && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if !tenantAccessAllowed(sess, acc.TenantID) { writeError(w, http.StatusForbidden, "access denied") return } if err := s.imapScheduler.TriggerSync(r.Context(), id); err != nil { s.logger.Error("trigger sync failed", "err", err) writeError(w, http.StatusConflict, "sync already running or failed to start") 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 or full credentials // of an IMAP account. When name/host/username are present the credentials are updated; // otherwise only sync_interval_min is changed. 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 && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if !tenantAccessAllowed(sess, acc.TenantID) { writeError(w, http.StatusForbidden, "access denied") return } var req struct { SyncIntervalMin int `json:"sync_interval_min"` Name string `json:"name"` 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 } // Credential update when name/host/username are provided. if req.Name != "" || req.Host != "" || req.Username != "" { if req.Name == "" || req.Host == "" || req.Username == "" { writeError(w, http.StatusBadRequest, "name, host and username are required for credential update") return } port := req.Port if port == 0 { port = acc.Port } tls := req.TLS if tls == "" { tls = acc.TLS } updated := imapstore.Account{Name: req.Name, Host: req.Host, Port: port, TLS: tls, Username: req.Username} if err := s.imapStore.UpdateCredentials(r.Context(), id, updated, req.Password); err != nil { writeError(w, http.StatusInternalServerError, "failed to update credentials") return } acc, err = s.imapStore.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to reload account") return } writeJSON(w, http.StatusOK, acc) return } // Sync interval update. // 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) } // ── POP3 handlers ────────────────────────────────────────────────────────── func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) { if s.pop3Store == nil { writeError(w, http.StatusServiceUnavailable, "POP3 not configured") return } sess := sessionFromCtx(r.Context()) // SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin). isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin) accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin, sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts") return } if accounts == nil { accounts = []pop3store.Account{} } writeJSON(w, http.StatusOK, accounts) } func (s *Server) handleCreatePop3(w http.ResponseWriter, r *http.Request) { if s.pop3Store == nil { writeError(w, http.StatusServiceUnavailable, "POP3 not configured") return } var req struct { Name string `json:"name"` Host string `json:"host"` Port int `json:"port"` TLS string `json:"tls"` TLSSkipVerify bool `json:"tls_skip_verify"` 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.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 = 110 } if req.TLS == "" { req.TLS = "none" } sess := sessionFromCtx(r.Context()) acc := pop3store.Account{ Owner: sess.Username, Name: req.Name, Host: req.Host, Port: req.Port, TLS: req.TLS, TLSSkipVerify: req.TLSSkipVerify, Username: req.Username, TenantID: sess.TenantID, } created, err := s.pop3Store.Create(r.Context(), acc, req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create POP3 account") return } writeJSON(w, http.StatusCreated, created) } func (s *Server) handleDeletePop3(w http.ResponseWriter, r *http.Request) { if s.pop3Store == nil { writeError(w, http.StatusServiceUnavailable, "POP3 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.pop3Store.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if err := s.pop3Store.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) handleTestPop3(w http.ResponseWriter, r *http.Request) { var req struct { Host string `json:"host"` Port int `json:"port"` TLS string `json:"tls"` TLSSkipVerify bool `json:"tls_skip_verify"` 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 = 110 } if req.TLS == "" { req.TLS = "none" } c, err := pop3store.Dial(req.Host, req.Port, req.TLS, req.TLSSkipVerify) if err != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": false, "message": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err), }) return } defer c.Close() if err := c.Login(req.Username, req.Password); err != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": false, "message": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err), }) return } count, totalSize, err := c.Stat() if err != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": false, "message": fmt.Sprintf("STAT fehlgeschlagen: %v", err), }) return } _ = c.Quit() writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "message": fmt.Sprintf("Verbindung erfolgreich: %d E-Mails", count), "message_count": count, "total_size_bytes": totalSize, }) } func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) { if s.pop3Store == nil || s.pop3Importer == nil { writeError(w, http.StatusServiceUnavailable, "POP3 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.pop3Store.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if acc.Status == "running" { writeError(w, http.StatusConflict, "import already running") return } go s.pop3Importer.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) handlePop3Progress(w http.ResponseWriter, r *http.Request) { if s.pop3Store == nil { writeError(w, http.StatusServiceUnavailable, "POP3 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.pop3Store.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "account not found") return } sess := sessionFromCtx(r.Context()) if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } if !tenantAccessAllowed(sess, acc.TenantID) { writeError(w, http.StatusForbidden, "access denied") return } writeJSON(w, http.StatusOK, acc) }