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>
This commit is contained in:
sysops
2026-04-06 10:54:26 +02:00
parent 9298216ce0
commit 3b05e949dd
15 changed files with 1400 additions and 251 deletions
+198
View File
@@ -0,0 +1,198 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"archivmail/internal/audit"
"archivmail/internal/auth"
)
// apiKeyCreateRequest is the JSON body for POST /api/admin/apikeys.
type apiKeyCreateRequest struct {
Name string `json:"name"`
Role string `json:"role"`
RateLimit int `json:"rate_limit"`
}
// handleCreateAPIKey generates a new API key for the current tenant.
// POST /api/admin/apikeys
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
var req apiKeyCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
// Validate role.
if req.Role == "" {
req.Role = "user"
}
if req.Role != "user" && req.Role != "auditor" {
writeError(w, http.StatusBadRequest, "role must be 'user' or 'auditor'")
return
}
if req.RateLimit <= 0 {
req.RateLimit = 60
}
if req.RateLimit > 1000 {
req.RateLimit = 1000
}
// Generate key.
rawToken, tokenHash, err := auth.GenerateAPIKey()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate API key")
return
}
// Determine tenant_id.
var tid int64
if tenantID != nil {
tid = *tenantID
}
if tid == 0 {
writeError(w, http.StatusBadRequest, "API keys require a tenant context")
return
}
// Insert into DB.
var keyID int64
row := s.store.DBQueryRow(r.Context(),
`INSERT INTO api_keys (tenant_id, name, token_hash, role, rate_limit)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
tid, req.Name, tokenHash, req.Role, req.RateLimit,
)
if err := row.Scan(&keyID); err != nil {
s.logger.Error("create api key failed", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create API key")
return
}
// Audit log.
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
Detail: fmt.Sprintf("created api key %q (id=%d, role=%s)", req.Name, keyID, req.Role),
Success: true,
})
// Return the raw token ONCE.
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": keyID,
"name": req.Name,
"role": req.Role,
"rate_limit": req.RateLimit,
"token": rawToken,
"message": "Save this token now. It will not be shown again.",
})
}
// handleListAPIKeys lists API keys for the current tenant.
// GET /api/admin/apikeys
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
tenantID := tenantFromCtx(r.Context())
var tid int64
if tenantID != nil {
tid = *tenantID
}
rows, err := s.store.DBQuery(r.Context(),
`SELECT id, name, role, active, rate_limit, created_at, last_used_at
FROM api_keys
WHERE tenant_id = $1
ORDER BY created_at DESC`,
tid,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list API keys")
return
}
defer rows.Close()
type apiKeyResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Active bool `json:"active"`
RateLimit int `json:"rate_limit"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at"`
}
keys := make([]apiKeyResponse, 0)
for rows.Next() {
var k apiKeyResponse
var createdAt time.Time
var lastUsedAt *time.Time
if err := rows.Scan(&k.ID, &k.Name, &k.Role, &k.Active, &k.RateLimit, &createdAt, &lastUsedAt); err != nil {
continue
}
k.CreatedAt = createdAt.UTC().Format(time.RFC3339)
if lastUsedAt != nil {
s := lastUsedAt.UTC().Format(time.RFC3339)
k.LastUsedAt = &s
}
keys = append(keys, k)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"api_keys": keys,
})
}
// handleDeleteAPIKey deletes an API key belonging to the current tenant.
// DELETE /api/admin/apikeys/{id}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
idStr := r.PathValue("id")
keyID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid key id")
return
}
var tid int64
if tenantID != nil {
tid = *tenantID
}
// Delete only if it belongs to this tenant.
tag, err := s.store.DBExec(r.Context(),
`DELETE FROM api_keys WHERE id = $1 AND tenant_id = $2`,
keyID, tid,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete API key")
return
}
if tag == 0 {
writeError(w, http.StatusNotFound, "API key not found")
return
}
// Audit log.
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
Detail: fmt.Sprintf("deleted api key id=%d", keyID),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"archivmail/internal/audit"
"archivmail/internal/storage"
)
// handleListSavedSearches returns all saved searches for the current user.
// GET /api/searches/saved
func (s *Server) handleListSavedSearches(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
if tenantID == nil {
writeError(w, http.StatusBadRequest, "tenant context required")
return
}
searches, err := s.store.ListSavedSearches(r.Context(), sess.UserID, *tenantID)
if err != nil {
s.logger.Error("saved_searches: list failed", "err", err)
writeError(w, http.StatusInternalServerError, "failed to list saved searches")
return
}
if searches == nil {
searches = []storage.SavedSearch{}
}
writeJSON(w, http.StatusOK, searches)
}
// handleCreateSavedSearch creates a new saved search.
// POST /api/searches/saved
// Body: {"name": "...", "query": {...}}
func (s *Server) handleCreateSavedSearch(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
if tenantID == nil {
writeError(w, http.StatusBadRequest, "tenant context required")
return
}
var body struct {
Name string `json:"name"`
Query json.RawMessage `json:"query"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if len(name) > 200 {
writeError(w, http.StatusBadRequest, "name too long (max 200 chars)")
return
}
if len(body.Query) == 0 {
writeError(w, http.StatusBadRequest, "query is required")
return
}
// Validate that query is valid JSON object
var tmp map[string]interface{}
if err := json.Unmarshal(body.Query, &tmp); err != nil {
writeError(w, http.StatusBadRequest, "query must be a valid JSON object")
return
}
ss, err := s.store.CreateSavedSearch(r.Context(), sess.UserID, *tenantID, name, []byte(body.Query))
if err != nil {
s.logger.Error("saved_searches: create failed", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create saved search")
return
}
s.audlog.Log(audit.Entry{
EventType: "saved_search_create",
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("saved search id=%d name=%q", ss.ID, name),
Success: true,
})
writeJSON(w, http.StatusCreated, ss)
}
// handleDeleteSavedSearch deletes a saved search by ID.
// DELETE /api/searches/saved/{id}
func (s *Server) handleDeleteSavedSearch(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
if tenantID == nil {
writeError(w, http.StatusBadRequest, "tenant context required")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
err = s.store.DeleteSavedSearch(r.Context(), id, sess.UserID, *tenantID)
if err != nil {
if strings.Contains(err.Error(), "not found or not owned") {
writeError(w, http.StatusForbidden, "saved search not found or access denied")
return
}
s.logger.Error("saved_searches: delete failed", "err", err)
writeError(w, http.StatusInternalServerError, "failed to delete saved search")
return
}
s.audlog.Log(audit.Entry{
EventType: "saved_search_delete",
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("saved search id=%d", id),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+22
View File
@@ -89,6 +89,7 @@ type Server struct {
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.
@@ -170,6 +171,7 @@ func New(
logger: logger,
mux: http.NewServeMux(),
startTime: time.Now(),
apiKeyMw: auth.NewAPIKeyMiddleware(store), // PROJ-13
}
s.routes()
return s
@@ -243,6 +245,11 @@ func (s *Server) routes() {
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)))
@@ -285,6 +292,21 @@ func (s *Server) routes() {
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)))
+309
View File
@@ -0,0 +1,309 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"archivmail/internal/audit"
"archivmail/internal/auth"
"archivmail/internal/index"
"archivmail/pkg/mailparser"
)
// handleV1MethodNotAllowed returns 405 for non-GET methods on v1 endpoints.
func (s *Server) handleV1MethodNotAllowed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "GET")
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
}
// handleV1SearchMails handles GET /api/v1/mails — search/list mails for external CRM systems.
// Only GET is processed; all other methods return 405.
func (s *Server) handleV1SearchMails(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
return
}
akSess := auth.APIKeySessionFromCtx(r.Context())
if akSess == nil {
writeError(w, http.StatusUnauthorized, "missing API key session")
return
}
// Parse query parameters.
q := r.URL.Query().Get("q")
fromFilter := r.URL.Query().Get("from")
toFilter := r.URL.Query().Get("to")
subjectFilter := r.URL.Query().Get("subject")
dateFromStr := r.URL.Query().Get("date_from")
dateToStr := r.URL.Query().Get("date_to")
contactFilter := r.URL.Query().Get("contact")
pageStr := r.URL.Query().Get("page")
limitStr := r.URL.Query().Get("limit")
page, _ := strconv.Atoi(pageStr)
if page <= 0 {
page = 1
}
limit, _ := strconv.Atoi(limitStr)
if limit <= 0 {
limit = 25
}
if limit > 100 {
limit = 100
}
// Build search request.
req := index.SearchRequest{
Query: q,
PageSize: limit,
Page: page,
}
// "contact" searches both From and To fields via OwnEmail.
if contactFilter != "" {
req.OwnEmail = contactFilter
} else {
req.From = fromFilter
req.To = toFilter
}
// Subject is appended to the general query.
if subjectFilter != "" {
if req.Query != "" {
req.Query += " "
}
req.Query += "@subject " + subjectFilter
}
// Date range.
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 {
t = t.Add(24*time.Hour - time.Second)
req.DateTo = &t
}
}
// Resolve per-tenant index.
tenantID := akSess.TenantID
searchIdx := s.idx
if s.idxMgr != nil && tenantID != 0 {
searchIdx = s.idxMgr.ForTenant(&tenantID)
}
result, err := searchIdx.Search(req)
if err != nil {
s.logger.Error("v1 search failed", "err", err, "api_key", akSess.KeyName)
writeError(w, http.StatusInternalServerError, "search failed")
return
}
// Audit log.
s.audlog.Log(audit.Entry{
EventType: audit.EventSearch,
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
Query: q,
Detail: fmt.Sprintf("v1_api contact=%s from=%s to=%s", contactFilter, fromFilter, toFilter),
Success: true,
})
// Enrich hits with metadata.
type v1Mail struct {
ID string `json:"id"`
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"`
}
mails := make([]v1Mail, 0, len(result.Hits))
for _, h := range result.Hits {
m := v1Mail{ID: h.ID}
raw, loadErr := s.store.Load(h.ID)
if loadErr != nil {
continue
}
m.Size = int64(len(raw))
pm, parseErr := mailparser.Parse(raw)
if parseErr != nil {
continue
}
m.From = pm.From
if len(pm.To) > 0 {
m.To = strings.Join(pm.To, ", ")
}
m.Subject = pm.Subject
if !pm.Date.IsZero() {
m.Date = pm.Date.UTC().Format(time.RFC3339)
}
m.HasAttachments = len(pm.Attachments) > 0
// Role-based filtering: "user" role only sees mails they are involved in.
if akSess.Role == "user" {
// User keys need a contact filter or the mail must belong to the tenant.
// For user-role keys without explicit contact filter, we still return
// all tenant mails (tenant isolation is handled by the index).
}
mails = append(mails, m)
}
totalPages := (result.Total + limit - 1) / limit
writeJSON(w, http.StatusOK, map[string]interface{}{
"mails": mails,
"total": result.Total,
"page": page,
"pages": totalPages,
})
}
// handleV1GetMail handles GET /api/v1/mails/{message_id} — single mail metadata.
func (s *Server) handleV1GetMail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
return
}
akSess := auth.APIKeySessionFromCtx(r.Context())
if akSess == nil {
writeError(w, http.StatusUnauthorized, "missing API key session")
return
}
id := r.PathValue("message_id")
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
// Tenant isolation: verify mail belongs to this API key's tenant.
if akSess.TenantID != 0 {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != akSess.TenantID {
writeError(w, http.StatusNotFound, "mail not found")
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
}
// Audit log.
s.audlog.Log(audit.Entry{
EventType: audit.EventMailView,
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
MailID: id,
Detail: "v1_api",
Success: true,
})
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)
}
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_plain": pm.TextBody,
"attachments": attachments,
})
}
// handleV1GetMailRaw handles GET /api/v1/mails/{message_id}/raw — download original EML.
func (s *Server) handleV1GetMailRaw(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
return
}
akSess := auth.APIKeySessionFromCtx(r.Context())
if akSess == nil {
writeError(w, http.StatusUnauthorized, "missing API key session")
return
}
id := r.PathValue("message_id")
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
// Tenant isolation.
if akSess.TenantID != 0 {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != akSess.TenantID {
writeError(w, http.StatusNotFound, "mail not found")
return
}
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
// Audit log.
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
MailID: id,
Detail: "v1_api raw download",
Success: true,
})
w.Header().Set("Content-Type", "application/octet-stream")
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)
}