Files
archivmail/internal/api/server.go
T
sysops 3b05e949dd feat(PROJ-13,PROJ-42): REST API v1 + Gespeicherte Suchanfragen
PROJ-13: Externe REST API für CRM/ERP-Anbindung
- API-Key Middleware mit SHA-256-Hash-Lookup + Token-Bucket Rate-Limiter
- GET /api/v1/mails — Suche mit Paginierung (max 100/Seite)
- GET /api/v1/mails/{id} — Mail-Metadaten als JSON
- GET /api/v1/mails/{id}/raw — Original-EML Download
- Admin-Endpoints: POST/GET/DELETE /api/admin/apikeys
- Tenant-Isolation, Audit-Log, 405 für non-GET Methoden

PROJ-42: Gespeicherte Suchanfragen
- Tabelle saved_searches (user_id, tenant_id, name, query_json)
- GET/POST/DELETE /api/searches/saved mit Ownership-Check
- Frontend: "Suche speichern"-Button + Popover mit gespeicherten Suchen
- shadcn/ui Komponenten, Loading/Empty States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:54:26 +02:00

508 lines
20 KiB
Go

package api
import (
"context"
"encoding/json"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"regexp"
"archivmail/config"
"archivmail/internal/audit"
"archivmail/internal/auth"
imapstore "archivmail/internal/imap"
"archivmail/internal/index"
ldapcfg "archivmail/internal/ldapconfig"
"archivmail/internal/mailer"
pop3store "archivmail/internal/pop3"
"archivmail/internal/smtpoutconfig"
"archivmail/internal/smtpd"
"archivmail/internal/storage"
"archivmail/internal/tenantstore"
"archivmail/internal/tokenstore"
"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
metricsCfg config.MetricsConfig
startTime time.Time
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
ldapStore *ldapcfg.Store
tenantStore *tenantstore.Store
tenantLdapStore *ldapcfg.TenantStore
idxMgr index.TenantIndexer
appVersion string
moduleVersions map[string]string
globalRetentionDays int // from storage config (PROJ-34)
mailer *mailer.Mailer
tokenStore *tokenstore.Store
fqdn string // from server.fqdn config (PROJ-28)
smtpOutStore *smtpoutconfig.Store
apiKeyMw *auth.APIKeyMiddleware // PROJ-13: external API auth
}
// 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.TenantIndexer) {
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
}
// SetMetrics wires the metrics config into the API server.
func (s *Server) SetMetrics(cfg config.MetricsConfig) {
s.metricsCfg = cfg
}
// SetMailer wires the SMTP-Out mailer into the API server (PROJ-28).
func (s *Server) SetMailer(m *mailer.Mailer) {
s.mailer = m
}
// SetTokenStore wires the token store into the API server (PROJ-28).
func (s *Server) SetTokenStore(ts *tokenstore.Store) {
s.tokenStore = ts
}
// SetFQDN wires the server FQDN for link generation (PROJ-28).
func (s *Server) SetFQDN(fqdn string) {
s.fqdn = fqdn
}
// SetSMTPOutStore wires the SMTP-Out config store into the API server.
func (s *Server) SetSMTPOutStore(store *smtpoutconfig.Store) {
s.smtpOutStore = store
}
// 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(),
startTime: time.Now(),
apiKeyMw: auth.NewAPIKeyMiddleware(store), // PROJ-13
}
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 /metrics", s.handleMetrics)
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))
// PROJ-28: Self-Service Onboarding
s.mux.HandleFunc("POST /api/auth/signup", s.handleSignup)
s.mux.HandleFunc("GET /api/auth/verify", s.handleVerifyEmail)
s.mux.HandleFunc("POST /api/auth/forgot-password", s.handleForgotPassword)
s.mux.HandleFunc("POST /api/auth/reset-password", s.handleResetPassword)
s.mux.HandleFunc("GET /api/auth/invite", s.handleCheckInvite)
s.mux.HandleFunc("POST /api/admin/invite", s.authAdmin(s.handleCreateInvite))
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/threads/{threadID}", s.auth(s.handleGetThread))
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/stats/timeseries", s.authAdmin(s.handleMailTimeseries))
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)))
// SMTP-Out Relay Konfiguration — superadmin only
s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut)))
s.mux.HandleFunc("PUT /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSaveSMTPOut)))
s.mux.HandleFunc("DELETE /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteSMTPOut)))
s.mux.HandleFunc("POST /api/admin/smtp-out/test", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleTestSMTPOut)))
// 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)))
// Spec-konforme Alias-Route (PROJ-29 Acceptance Criterion 4)
s.mux.HandleFunc("GET /api/admin/tenants/{id}/usage", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage)))
// 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))
// PROJ-42: Gespeicherte Suchanfragen
s.mux.HandleFunc("GET /api/searches/saved", s.auth(s.handleListSavedSearches))
s.mux.HandleFunc("POST /api/searches/saved", s.auth(s.handleCreateSavedSearch))
s.mux.HandleFunc("DELETE /api/searches/saved/{id}", s.auth(s.handleDeleteSavedSearch))
// 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)))
s.mux.HandleFunc("POST /api/export/ediscovery", s.auth(s.handleExportEDiscovery))
// 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))
// PROJ-13: External REST API v1 (API-key auth)
s.mux.HandleFunc("/api/v1/mails", s.apiKeyMw.Wrap(s.handleV1SearchMails))
s.mux.HandleFunc("GET /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1GetMail))
s.mux.HandleFunc("GET /api/v1/mails/{message_id}/raw", s.apiKeyMw.Wrap(s.handleV1GetMailRaw))
// PROJ-13: Catch-all for non-GET methods on v1 single-mail endpoints
s.mux.HandleFunc("POST /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
s.mux.HandleFunc("PUT /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
s.mux.HandleFunc("DELETE /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
s.mux.HandleFunc("PATCH /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
// PROJ-13: API key management (admin)
s.mux.HandleFunc("POST /api/admin/apikeys", s.authAdmin(s.handleCreateAPIKey))
s.mux.HandleFunc("GET /api/admin/apikeys", s.authAdmin(s.handleListAPIKeys))
s.mux.HandleFunc("DELETE /api/admin/apikeys/{id}", s.authAdmin(s.handleDeleteAPIKey))
// 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 ---
// --- 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
}