// Package mailrulesapi implementiert INT-06: die E-Mail-Regel-Engine // (mail/internal/mailrules, IMP-03) über REST steuerbar machen — // Anlegen, Ändern, Löschen, Priorität einsehen/ändern. // // Core API-01 hat weiterhin keinen abrufbaren Router (gleiche, // mehrfach dokumentierte Situation wie mailapi/INT-01) — // RegisterRoutes registriert die Endpunkte auf einem vom Aufrufer // bereitgestellten *http.ServeMux mit demselben Pfadschema. // // Akzeptanzkriterium 3 ("API-Änderungen wirken identisch zur // bisherigen internen Regel-Anwendung") ist strukturell garantiert: // dieses Paket ruft AUSSCHLIESSLICH mail/internal/mailrules.Store auf // — denselben Store, den IMP-03s Import-Pfad ohnehin verwendet. Es // gibt keinen zweiten, parallelen Schreibpfad, der abweichen könnte. package mailrulesapi import ( "context" "encoding/json" "errors" "net/http" "strconv" "strings" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" ) // RulesStore ist die für diese API benötigte Teilmenge von // *mailrules.Store — als Schnittstelle für Tests ohne echte Postgres- // Instanz. type RulesStore interface { Create(ctx context.Context, tenantSlug string, rule mailrules.Rule) (int64, error) List(ctx context.Context, tenantSlug string) ([]mailrules.Rule, error) Update(ctx context.Context, tenantSlug string, id int64, rule mailrules.Rule) error Delete(ctx context.Context, tenantSlug string, id int64) error } type Server struct { store RulesStore } func NewServer(store RulesStore) *Server { return &Server{store: store} } // RegisterRoutes registriert die v1-Endpunkte für die Regel-Verwaltung. func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/mail/rules", s.handleList) mux.HandleFunc("POST /api/v1/mail/rules", s.handleCreate) mux.HandleFunc("PUT /api/v1/mail/rules/{id}", s.handleUpdate) mux.HandleFunc("DELETE /api/v1/mail/rules/{id}", s.handleDelete) } type errorResponse struct { Error string `json:"error"` } func writeError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(errorResponse{Error: message}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // requireTenant liest den Pflicht-Query-Parameter "tenant" — dieselbe // Konvention wie mail/internal/mailapi (INT-01). func requireTenant(w http.ResponseWriter, r *http.Request) (string, bool) { tenant := strings.TrimSpace(r.URL.Query().Get("tenant")) if tenant == "" { writeError(w, http.StatusBadRequest, "fehlender oder leerer tenant-kontext (query-parameter \"tenant\")") return "", false } return tenant, true } // ruleDTO ist die JSON-Darstellung EINER Regel — 1:1 zu // mailrules.Rule, aber als eigener Typ, damit das Übertragungsformat // unabhängig vom internen Go-Struct bleibt. type ruleDTO struct { ID int64 `json:"id,omitempty"` Name string `json:"name"` SenderPattern string `json:"senderPattern"` SubjectPattern string `json:"subjectPattern"` MailboxPattern string `json:"mailboxPattern"` AttachmentTypePattern string `json:"attachmentTypePattern"` Category string `json:"category"` Tag string `json:"tag"` // Priority: niedrigere Zahl = höhere Priorität (Akzeptanzkriterium 2), // dieselbe Semantik wie mailrules.Rule.Priority. Priority int `json:"priority"` } func toDTO(r mailrules.Rule) ruleDTO { return ruleDTO{ ID: r.ID, Name: r.Name, SenderPattern: r.SenderPattern, SubjectPattern: r.SubjectPattern, MailboxPattern: r.MailboxPattern, AttachmentTypePattern: r.AttachmentTypePattern, Category: r.Category, Tag: r.Tag, Priority: r.Priority, } } func fromDTO(dto ruleDTO) mailrules.Rule { return mailrules.Rule{ Name: dto.Name, SenderPattern: dto.SenderPattern, SubjectPattern: dto.SubjectPattern, MailboxPattern: dto.MailboxPattern, AttachmentTypePattern: dto.AttachmentTypePattern, Category: dto.Category, Tag: dto.Tag, Priority: dto.Priority, } } type listRulesResponse struct { Rules []ruleDTO `json:"rules"` } // handleList ist GET /api/v1/mail/rules (Akzeptanzkriterium 2: // Prioritätsreihenfolge einsehbar — mailrules.Store.List liefert // bereits aufsteigend nach Priority sortiert). func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { tenant, ok := requireTenant(w, r) if !ok { return } rules, err := s.store.List(r.Context(), tenant) if err != nil { writeError(w, http.StatusBadGateway, "regeln abrufen fehlgeschlagen") return } resp := listRulesResponse{Rules: make([]ruleDTO, 0, len(rules))} for _, rule := range rules { resp.Rules = append(resp.Rules, toDTO(rule)) } writeJSON(w, http.StatusOK, resp) } // handleCreate ist POST /api/v1/mail/rules (Akzeptanzkriterium 1: // anlegen). func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { tenant, ok := requireTenant(w, r) if !ok { return } var dto ruleDTO if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { writeError(w, http.StatusBadRequest, "ungültiger anfragekörper") return } id, err := s.store.Create(r.Context(), tenant, fromDTO(dto)) if err != nil { writeError(w, http.StatusBadRequest, "regel anlegen fehlgeschlagen: ungültige eingabe") return } dto.ID = id writeJSON(w, http.StatusCreated, dto) } // handleUpdate ist PUT /api/v1/mail/rules/{id} (Akzeptanzkriterium 1: // ändern; Akzeptanzkriterium 2: Priorität änderbar — priority ist ein // normales Feld des Anfragekörpers). func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) { tenant, ok := requireTenant(w, r) if !ok { return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "ungültige regel-id") return } var dto ruleDTO if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { writeError(w, http.StatusBadRequest, "ungültiger anfragekörper") return } if err := s.store.Update(r.Context(), tenant, id, fromDTO(dto)); err != nil { if errors.Is(err, mailrules.ErrNotFound) { writeError(w, http.StatusNotFound, "regel nicht gefunden") return } writeError(w, http.StatusBadRequest, "regel aktualisieren fehlgeschlagen: ungültige eingabe") return } dto.ID = id writeJSON(w, http.StatusOK, dto) } // handleDelete ist DELETE /api/v1/mail/rules/{id} (Akzeptanzkriterium // 1: löschen). func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { tenant, ok := requireTenant(w, r) if !ok { return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "ungültige regel-id") return } if err := s.store.Delete(r.Context(), tenant, id); err != nil { writeError(w, http.StatusBadGateway, "regel löschen fehlgeschlagen") return } w.WriteHeader(http.StatusNoContent) }