Files
archivmail/internal/api/v1_handlers.go
T
sysops fa9f77782c fix(sec): Authorization-Bypässe und Path-Traversal schließen, Xapian-Doku bereinigen
- SEC: requireMailAccess auf GET /api/threads/{threadID} — superadmin/domain_admin konnten Mail-Metadaten lesen
- SEC: requireMailAccess auf POST /api/export/ediscovery — superadmin/domain_admin konnten bis zu 10k EML exportieren
- SEC: V1-API user-role Keys müssen 'contact=' angeben — verhindert vollständige Tenant-Enumeration
- SEC: Domain-Regex-Validierung in handleCertACME vor filepath.Join und certbot-Aufruf
- docs: README und config.test.yml auf Manticore Search aktualisiert (kein Xapian mehr)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:55:31 +02:00

317 lines
8.1 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,
}
// User-role keys must always scope their search to a specific contact address.
if akSess.Role == "user" && contactFilter == "" {
writeError(w, http.StatusBadRequest, "user-role API keys require the 'contact' parameter")
return
}
// "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
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)
}
// PROJ-44: expose ocr_status to external API consumers as well so CRM
// integrations can decide whether to ask for ocr-text downloads.
ocrStatus, _, _ := s.store.GetOCRMeta(r.Context(), id)
if ocrStatus == "" {
ocrStatus = "pending"
}
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,
"ocr_status": ocrStatus,
})
}
// 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)
}