package api import ( "context" "encoding/json" "log/slog" "net" "net/http" "strings" "sync" "regexp" "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/labelstore" ldapcfg "github.com/archivmail/internal/ldapconfig" pop3store "github.com/archivmail/internal/pop3" "github.com/archivmail/internal/smtpd" "github.com/archivmail/internal/storage" "github.com/archivmail/internal/tenantstore" "github.com/archivmail/internal/userstore" ) // SEC-22: Compiled regex for mail ID validation to prevent path traversal. var mailIDRegex = regexp.MustCompile(`^[0-9a-f]{64}$`) // isValidMailID validates that a mail ID matches the expected hex format. func isValidMailID(id string) bool { return mailIDRegex.MatchString(id) } // roleLevel returns the privilege level for a role string. // Hierarchy: superadmin=5 > domain_admin=4 > domain_auditor=3 > auditor=2 > user=1 // Separation of duties: admins (superadmin, domain_admin) have NO mail access. // Mail access: domain_auditor (all tenant mails), auditor (own mails), user (own mails). func roleLevel(role string) int { levels := map[string]int{ userstore.RoleUser: 1, userstore.RoleAuditor: 2, userstore.RoleDomainAuditor: 3, userstore.RoleDomainAdmin: 4, userstore.RoleAdmin: 4, // legacy alias for domain_admin userstore.RoleSuperAdmin: 5, } return levels[role] } type contextKey string const ( sessionKey contextKey = "session" tenantKey contextKey = "tenant_id" ) // 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 pop3Store *pop3store.Store pop3Importer *pop3store.Importer uploadJobs sync.Map // jobID → *UploadJob labels *labelstore.Store ldapStore *ldapcfg.Store tenantStore *tenantstore.Store tenantLdapStore *ldapcfg.TenantStore idxMgr *index.TenantIndexManager appVersion string moduleVersions map[string]string globalRetentionDays int // from storage config (PROJ-34) } // 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 } // SetPop3 wires the POP3 store and importer into the API server after construction. func (s *Server) SetPop3(store *pop3store.Store, importer *pop3store.Importer) { s.pop3Store = store s.pop3Importer = importer } // SetIndexManager wires the per-tenant index manager into the API server (PROJ-21 Phase 4). func (s *Server) SetIndexManager(mgr *index.TenantIndexManager) { s.idxMgr = mgr } // SetVersion wires app version and module versions into the API server. func (s *Server) SetVersion(appVersion string, modules map[string]string) { s.appVersion = appVersion s.moduleVersions = modules } // SetGlobalRetentionDays wires the global retention_days from storage config into the API server. func (s *Server) SetGlobalRetentionDays(days int) { s.globalRetentionDays = days } // 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 } // auth wraps a handler with authentication + tenant context propagation. func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc { return s.authMiddleware(s.tenantMiddleware(h)) } // authAdmin wraps a handler requiring at least admin role. func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc { return s.authMiddleware(s.tenantMiddleware(s.requireRole(userstore.RoleDomainAdmin, h))) } func (s *Server) routes() { s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.HandleFunc("GET /api/version", s.handleVersion) s.mux.HandleFunc("POST /api/auth/login", s.handleLogin) s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe)) s.mux.HandleFunc("POST /api/auth/logout", s.auth(s.handleLogout)) s.mux.HandleFunc("GET /api/users", s.authAdmin(s.handleListUsers)) s.mux.HandleFunc("POST /api/users", s.authAdmin(s.handleCreateUser)) s.mux.HandleFunc("PATCH /api/users/{id}", s.authAdmin(s.handleUpdateUser)) s.mux.HandleFunc("DELETE /api/users/{id}", s.authAdmin(s.handleDeleteUser)) s.mux.HandleFunc("GET /api/search", s.auth(s.handleSearch)) s.mux.HandleFunc("GET /api/audit", s.auth(s.requireRole(userstore.RoleAuditor, s.handleAuditLog))) s.mux.HandleFunc("GET /api/admin/smtp/status", s.authAdmin(s.handleSMTPStatus)) s.mux.HandleFunc("GET /api/admin/storage/stats", s.authAdmin(s.handleStorageStats)) s.mux.HandleFunc("GET /api/mails/{id}", s.auth(s.requireMailAccess(s.handleGetMail))) s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.auth(s.requireMailAccess(s.handleGetAttachment))) s.mux.HandleFunc("GET /api/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw))) s.mux.HandleFunc("GET /api/admin/services", s.authAdmin(s.handleListServices)) s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authAdmin(s.handleServiceAction)) s.mux.HandleFunc("GET /api/admin/system/stats", s.authAdmin(s.handleSystemStats)) s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit)) // SEC-17: Security fix actions require superadmin, not just domain_admin. s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix))) // PROJ-34: Retention — superadmin only s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge))) s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention))) s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention))) // PROJ-29: Quotas — superadmin only s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage))) s.mux.HandleFunc("GET /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage))) s.mux.HandleFunc("PUT /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantQuota))) // PROJ-33: IMAP mode settings — domain_admin only s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode)) s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode)) // Export routes s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF))) s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP))) // Upload routes (admin only) s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload)) s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authAdmin(s.handleUploadProgress)) // Upload routes (all authenticated users) s.mux.HandleFunc("POST /api/upload", s.auth(s.handleUpload)) s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.auth(s.handleUploadProgress)) // IMAP routes (accessible to all authenticated users) s.mux.HandleFunc("GET /api/imap", s.auth(s.handleListImap)) s.mux.HandleFunc("POST /api/imap", s.auth(s.handleCreateImap)) s.mux.HandleFunc("DELETE /api/imap/{id}", s.auth(s.handleDeleteImap)) s.mux.HandleFunc("PATCH /api/imap/{id}", s.auth(s.handleUpdateImapInterval)) s.mux.HandleFunc("POST /api/imap/test", s.auth(s.handleTestImap)) s.mux.HandleFunc("POST /api/imap/{id}/import", s.auth(s.handleStartImport)) s.mux.HandleFunc("GET /api/imap/{id}/progress", s.auth(s.handleImapProgress)) s.mux.HandleFunc("POST /api/imap/{id}/sync", s.auth(s.handleSyncNow)) // POP3 routes (accessible to all authenticated users) s.mux.HandleFunc("GET /api/pop3", s.auth(s.handleListPop3)) s.mux.HandleFunc("POST /api/pop3", s.auth(s.handleCreatePop3)) s.mux.HandleFunc("DELETE /api/pop3/{id}", s.auth(s.handleDeletePop3)) s.mux.HandleFunc("POST /api/pop3/test", s.auth(s.handleTestPop3)) s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import)) s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress)) // PROJ-25: Profile routes (password & email change) s.mux.HandleFunc("PATCH /api/auth/password", s.auth(s.handleChangePassword)) s.mux.HandleFunc("PATCH /api/auth/email", s.auth(s.handleChangeEmail)) // PROJ-24: TOTP 2FA routes s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet)) s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost)) s.mux.HandleFunc("DELETE /api/auth/totp", s.auth(s.handleTOTPDisable)) s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset)) // Certificate management routes (superadmin only) s.mux.HandleFunc("GET /api/admin/cert/info", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertInfo))) s.mux.HandleFunc("POST /api/admin/cert/upload", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertUpload))) s.mux.HandleFunc("POST /api/admin/cert/self-signed", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertSelfSigned))) s.mux.HandleFunc("POST /api/admin/cert/acme", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertACME))) } // 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"}) } // --- 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) } } // ── Mail access middleware ──────────────────────────────────────────────── // requireMailAccess checks that the caller may read mail content. // SEC-29: Strict separation of duties — admins manage, auditors review. // Mail access is granted ONLY to: user, auditor, domain_auditor. // superadmin and domain_admin are explicitly denied (manage system/tenant, not content). func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } // SEC-29: Admins must not access mail content. switch sess.Role { case userstore.RoleSuperAdmin, userstore.RoleDomainAdmin: writeError(w, http.StatusForbidden, "access denied") 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{} } // tenantMiddleware extracts the tenant_id from the session and stores it in // the request context, making it available to all downstream handlers. func (s *Server) tenantMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { session := sessionFromCtx(r.Context()) if session != nil && session.TenantID != nil { ctx := context.WithValue(r.Context(), tenantKey, session.TenantID) next(w, r.WithContext(ctx)) return } next(w, r) } } // tenantFromCtx extracts the tenant_id from context. Returns nil for global (superadmin) context. func tenantFromCtx(ctx context.Context) *int64 { v, _ := ctx.Value(tenantKey).(*int64) return v } // sanitizeFilename strips characters that could be used for HTTP header injection // (quotes, newlines, control chars) from attachment filenames coming from parsed // e-mails. Only alphanumerics, spaces, dots, hyphens, and underscores are kept. func sanitizeFilename(name string) string { var b strings.Builder for _, r := range name { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == ' ' { b.WriteRune(r) } } return b.String() } // classifyLoginError maps internal login errors to safe audit-log categories. // Raw error messages must not be stored in audit logs since auditor-role // users can read them via GET /api/audit and internal details (LDAP hostnames, // port numbers, etc.) would be exposed. func classifyLoginError(err error) string { if err == nil { return "" } msg := err.Error() switch { case strings.Contains(msg, "not found"), strings.Contains(msg, "invalid password"), strings.Contains(msg, "invalid credentials"): return "invalid_password" case strings.Contains(msg, "ldap"), strings.Contains(msg, "LDAP"): return "ldap_error" case strings.Contains(msg, "disabled"), strings.Contains(msg, "inactive"): return "account_disabled" default: return "unknown" } } // remoteIP returns the real client IP. // X-Forwarded-For is only trusted when the direct connection comes from a // configured trusted proxy. Otherwise r.RemoteAddr is used directly to prevent // clients from spoofing their IP in audit logs and rate-limit counters. func (s *Server) remoteIP(r *http.Request) string { directIP, _, _ := net.SplitHostPort(r.RemoteAddr) if directIP == "" { directIP = r.RemoteAddr } if len(s.cfg.TrustedProxies) > 0 && isTrustedProxy(directIP, s.cfg.TrustedProxies) { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { return strings.TrimSpace(strings.Split(fwd, ",")[0]) } } return directIP } // isTrustedProxy checks whether ip is in the list of trusted proxy addresses/CIDRs. func isTrustedProxy(ip string, proxies []string) bool { parsed := net.ParseIP(ip) for _, p := range proxies { if strings.Contains(p, "/") { _, cidr, err := net.ParseCIDR(p) if err == nil && cidr.Contains(parsed) { return true } } else if p == ip { return true } } return false }