3b05e949dd
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>
310 lines
8.0 KiB
Go
310 lines
8.0 KiB
Go
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)
|
|
}
|