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) }