d79e334029
server.go (2357 -> 391 Zeilen) enthaelt nur noch Server-Struct, Konstruktor, Router, Middleware und Hilfsfunktionen. Neue Dateien: - auth_handlers.go: Login, Logout, Me - search_handlers.go: Suche, Mail-Anzeige, Anhaenge, Raw-Download - admin_handlers.go: User-CRUD, SMTP/Storage-Stats, Services, Security - import_handlers.go: IMAP + POP3 Account-Verwaltung und Import - dashboard_handlers.go: System-Stats, Audit-Log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
429 lines
12 KiB
Go
429 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/archivmail/internal/audit"
|
|
"github.com/archivmail/internal/index"
|
|
"github.com/archivmail/internal/userstore"
|
|
"github.com/archivmail/pkg/mailparser"
|
|
)
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query().Get("q")
|
|
fromFilter := r.URL.Query().Get("from")
|
|
toFilter := r.URL.Query().Get("to")
|
|
dateFromStr := r.URL.Query().Get("date_from")
|
|
dateToStr := r.URL.Query().Get("date_to")
|
|
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
|
|
hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false"
|
|
labelIDStr := r.URL.Query().Get("label_id") // PROJ-9: filter by label
|
|
pageStr := r.URL.Query().Get("page")
|
|
pageSizeStr := r.URL.Query().Get("page_size")
|
|
|
|
page, _ := strconv.Atoi(pageStr)
|
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
|
if pageSize <= 0 {
|
|
pageSize = 25
|
|
}
|
|
|
|
req := index.SearchRequest{
|
|
Query: q,
|
|
Sort: sortParam,
|
|
PageSize: pageSize,
|
|
Page: page,
|
|
}
|
|
|
|
// PROJ-9: Parse label_id filter.
|
|
if labelIDStr != "" {
|
|
if lid, err := strconv.ParseInt(labelIDStr, 10, 64); err == nil && lid > 0 {
|
|
req.LabelID = &lid
|
|
}
|
|
}
|
|
|
|
if hasAttachStr == "true" {
|
|
v := true
|
|
req.HasAttachment = &v
|
|
} else if hasAttachStr == "false" {
|
|
v := false
|
|
req.HasAttachment = &v
|
|
}
|
|
|
|
// Domain search: @domain.de matches both From AND To fields.
|
|
// A value starting with '@' triggers OR-search across XF and XT prefixes.
|
|
if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") {
|
|
domain := fromFilter
|
|
if domain == "" {
|
|
domain = toFilter
|
|
}
|
|
req.OwnEmail = domain
|
|
} else {
|
|
req.From = fromFilter
|
|
req.To = toFilter
|
|
}
|
|
|
|
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 {
|
|
// end of day for date_to
|
|
t = t.Add(24*time.Hour - time.Second)
|
|
req.DateTo = &t
|
|
}
|
|
}
|
|
|
|
// PROJ-21 Phase 4: Use per-tenant index when available; fall back to
|
|
// global index + post-filter when the tenant index manager is not wired.
|
|
tenantID := tenantFromCtx(r.Context())
|
|
searchIdx := s.idx
|
|
usedTenantIndex := false
|
|
if s.idxMgr != nil && tenantID != nil {
|
|
searchIdx = s.idxMgr.ForTenant(tenantID)
|
|
usedTenantIndex = true
|
|
}
|
|
|
|
result, err := searchIdx.Search(req)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "search failed")
|
|
return
|
|
}
|
|
|
|
// Fallback tenant isolation: post-filter when we used the global index
|
|
// but the user belongs to a tenant. This is the legacy path; the per-tenant
|
|
// index path above makes this unnecessary.
|
|
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 {
|
|
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
|
|
if idErr == nil {
|
|
allowed := make(map[string]struct{}, len(allowedIDs))
|
|
for _, id := range allowedIDs {
|
|
allowed[id] = struct{}{}
|
|
}
|
|
filtered := result.Hits[:0]
|
|
for _, h := range result.Hits {
|
|
if _, ok := allowed[h.ID]; ok {
|
|
filtered = append(filtered, h)
|
|
}
|
|
}
|
|
result.Hits = filtered
|
|
result.Total = len(filtered)
|
|
}
|
|
}
|
|
|
|
// PROJ-9: Post-filter by label_id when the label store is available.
|
|
if req.LabelID != nil && s.labels != nil && len(result.Hits) > 0 {
|
|
labelEmailIDs, lErr := s.labels.GetEmailIDsByLabel(r.Context(), *req.LabelID)
|
|
if lErr == nil {
|
|
allowed := make(map[string]struct{}, len(labelEmailIDs))
|
|
for _, id := range labelEmailIDs {
|
|
allowed[id] = struct{}{}
|
|
}
|
|
filtered := result.Hits[:0]
|
|
for _, h := range result.Hits {
|
|
if _, ok := allowed[h.ID]; ok {
|
|
filtered = append(filtered, h)
|
|
}
|
|
}
|
|
result.Hits = filtered
|
|
result.Total = len(filtered)
|
|
}
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventSearch,
|
|
Username: sess.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Query: q,
|
|
Success: true,
|
|
})
|
|
|
|
// Enrich hits with metadata (from, subject, date, size, attachments).
|
|
type enrichedHit struct {
|
|
ID string `json:"id"`
|
|
Score float64 `json:"score"`
|
|
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"`
|
|
LabelIDs []int64 `json:"label_ids,omitempty"` // PROJ-9
|
|
}
|
|
|
|
// PROJ-9: Batch-load label IDs for all hits.
|
|
var labelMap map[string][]int64
|
|
if s.labels != nil && len(result.Hits) > 0 {
|
|
emailIDs := make([]string, len(result.Hits))
|
|
for i, h := range result.Hits {
|
|
emailIDs[i] = h.ID
|
|
}
|
|
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
|
|
}
|
|
|
|
enriched := make([]enrichedHit, 0, len(result.Hits))
|
|
for _, h := range result.Hits {
|
|
eh := enrichedHit{ID: h.ID, Score: h.Score}
|
|
if raw, err := s.store.Load(h.ID); err == nil {
|
|
eh.Size = int64(len(raw))
|
|
if pm, err := mailparser.Parse(raw); err == nil {
|
|
eh.From = pm.From
|
|
if len(pm.To) > 0 {
|
|
eh.To = strings.Join(pm.To, ", ")
|
|
}
|
|
eh.Subject = pm.Subject
|
|
if !pm.Date.IsZero() {
|
|
eh.Date = pm.Date.UTC().Format(time.RFC3339)
|
|
}
|
|
eh.HasAttachments = len(pm.Attachments) > 0
|
|
}
|
|
}
|
|
if labelMap != nil {
|
|
eh.LabelIDs = labelMap[h.ID]
|
|
}
|
|
enriched = append(enriched, eh)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"total": result.Total,
|
|
"hits": enriched,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
|
if !isValidMailID(id) {
|
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
|
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
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
// Tenant isolation: domain_admin sees only own tenant's mail
|
|
if sess.TenantID != nil {
|
|
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
|
if mailTenant == nil || *mailTenant != *sess.TenantID {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
// user role: only own mailbox
|
|
if sess.Role == userstore.RoleUser {
|
|
u, err := s.users.GetByUsername(sess.Username)
|
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Verify status
|
|
vs, _ := s.store.GetVerifyStatus(r.Context(), id)
|
|
var verifyOK interface{} = nil
|
|
var verifiedAt interface{} = nil
|
|
if vs.VerifyOK != nil {
|
|
verifyOK = *vs.VerifyOK
|
|
}
|
|
if vs.VerifiedAt != nil {
|
|
verifiedAt = vs.VerifiedAt.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_html": pm.HTMLBody,
|
|
"body_plain": pm.TextBody,
|
|
"raw_headers": extractRawHeaders(raw),
|
|
"attachments": attachments,
|
|
"verify_ok": verifyOK,
|
|
"verified_at": verifiedAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
|
if !isValidMailID(id) {
|
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
|
return
|
|
}
|
|
indexStr := r.PathValue("index")
|
|
idx, err := strconv.Atoi(indexStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid attachment index")
|
|
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
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
// Tenant isolation
|
|
if sess.TenantID != nil {
|
|
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
|
if mailTenant == nil || *mailTenant != *sess.TenantID {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
if sess.Role == userstore.RoleUser {
|
|
u, err := s.users.GetByUsername(sess.Username)
|
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
if idx < 0 || idx >= len(pm.Attachments) {
|
|
writeError(w, http.StatusNotFound, "attachment not found")
|
|
return
|
|
}
|
|
|
|
a := pm.Attachments[idx]
|
|
filename := sanitizeFilename(a.Filename)
|
|
if filename == "" {
|
|
filename = fmt.Sprintf("attachment-%d", idx)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", a.ContentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(a.Data)))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(a.Data)
|
|
}
|
|
|
|
func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
|
if !isValidMailID(id) {
|
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
|
return
|
|
}
|
|
|
|
raw, err := s.store.Load(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "mail not found")
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
// Tenant isolation
|
|
if sess.TenantID != nil {
|
|
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
|
if mailTenant == nil || *mailTenant != *sess.TenantID {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
// SEC-28: Access check for user role — parse failure must NOT grant access.
|
|
if sess.Role == userstore.RoleUser {
|
|
pm, err := mailparser.Parse(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
|
return
|
|
}
|
|
u, err := s.users.GetByUsername(sess.Username)
|
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "message/rfc822")
|
|
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)
|
|
}
|
|
|
|
// mailBelongsToUser checks if the user's email appears in To or CC.
|
|
func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool {
|
|
email := strings.ToLower(userEmail)
|
|
for _, to := range pm.To {
|
|
if strings.ToLower(to) == email {
|
|
return true
|
|
}
|
|
}
|
|
for _, cc := range pm.CC {
|
|
if strings.ToLower(cc) == email {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractRawHeaders returns the header section of a raw RFC 2822 email.
|
|
func extractRawHeaders(raw []byte) string {
|
|
for i := 0; i < len(raw)-3; i++ {
|
|
if raw[i] == '\r' && raw[i+1] == '\n' && raw[i+2] == '\r' && raw[i+3] == '\n' {
|
|
return string(raw[:i])
|
|
}
|
|
if raw[i] == '\n' && raw[i+1] == '\n' {
|
|
return string(raw[:i])
|
|
}
|
|
}
|
|
return string(raw)
|
|
}
|