package api import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "time" "internal-mail-skill/internal/config" "internal-mail-skill/internal/db" "internal-mail-skill/internal/service" ) const Version = "0.1.0" type Server struct { cfg *config.Config store *db.Store svc *service.Service } func New(cfg *config.Config, store *db.Store, svc *service.Service) *Server { return &Server{cfg: cfg, store: store, svc: svc} } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.health) mux.HandleFunc("POST /sync", s.sync) mux.HandleFunc("GET /emails/search", s.searchEmails) mux.HandleFunc("GET /emails/{id}", s.getEmail) mux.HandleFunc("GET /emails/{id}/thread", s.getThread) mux.HandleFunc("POST /emails/{id}/mark", s.markEmail) mux.HandleFunc("POST /labels", s.createLabel) mux.HandleFunc("POST /emails/labels/apply", s.applyLabels) mux.HandleFunc("POST /drafts", s.createDraft) mux.HandleFunc("GET /drafts", s.listDrafts) mux.HandleFunc("PATCH /drafts/{id}", s.updateDraft) mux.HandleFunc("POST /drafts/{id}/send", s.sendDraft) mux.HandleFunc("POST /send", s.sendDirect) mux.HandleFunc("POST /emails/{id}/forward", s.forwardEmail) return withJSON(mux) } func (s *Server) health(w http.ResponseWriter, r *http.Request) { dbStatus := "ok" if err := s.store.Ping(r.Context()); err != nil { dbStatus = "down" } writeJSON(w, http.StatusOK, map[string]any{ "status": dbStatus, "version": Version, "database": dbStatus, "account_id": s.cfg.AccountID, "time": time.Now().UTC().Format(time.RFC3339), }) } func (s *Server) sync(w http.ResponseWriter, r *http.Request) { result, err := s.svc.Sync(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "sync_failed", err.Error(), nil) return } writeJSON(w, http.StatusOK, result) } func (s *Server) searchEmails(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() filters := service.SearchFilters{ Q: q.Get("q"), From: q.Get("from"), To: q.Get("to"), After: q.Get("after"), Before: q.Get("before"), Limit: intQuery(q.Get("limit"), 50), Offset: intQuery(q.Get("offset"), 0), } filters.HasAttachment = boolQuery(q.Get("has_attachment")) filters.Read = boolQuery(q.Get("read")) filters.Processed = boolQuery(q.Get("processed")) filters.Archived = boolQuery(q.Get("archived")) filters.Replied = boolQuery(q.Get("replied")) results, err := s.svc.SearchEmails(r.Context(), filters) if err != nil { writeError(w, http.StatusInternalServerError, "search_failed", err.Error(), nil) return } writeJSON(w, http.StatusOK, map[string]any{"emails": results, "limit": filters.Limit, "offset": filters.Offset}) } func (s *Server) getEmail(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } email, err := s.svc.GetEmail(r.Context(), id) if err != nil { handleServiceError(w, err) return } writeJSON(w, http.StatusOK, email) } func (s *Server) getThread(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } thread, err := s.svc.GetThread(r.Context(), id) if err != nil { handleServiceError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"thread": thread}) } func (s *Server) markEmail(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } var input service.MarkInput if !decodeBody(w, r, &input) { return } if err := s.svc.MarkEmail(r.Context(), id, input); err != nil { handleServiceError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) createLabel(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` Color string `json:"color"` } if !decodeBody(w, r, &input) { return } label, err := s.svc.CreateLabel(r.Context(), input.Name, input.Color) if err != nil { writeError(w, http.StatusBadRequest, "label_failed", err.Error(), nil) return } writeJSON(w, http.StatusCreated, label) } func (s *Server) applyLabels(w http.ResponseWriter, r *http.Request) { var input struct { EmailIDs []int64 `json:"email_ids"` LabelIDs []int64 `json:"label_ids"` Op string `json:"op"` } if !decodeBody(w, r, &input) { return } if err := s.svc.ApplyLabels(r.Context(), input.EmailIDs, input.LabelIDs, input.Op); err != nil { writeError(w, http.StatusBadRequest, "label_apply_failed", err.Error(), nil) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) createDraft(w http.ResponseWriter, r *http.Request) { var input service.DraftInput if !decodeBody(w, r, &input) { return } draft, err := s.svc.CreateDraft(r.Context(), input) if err != nil { writeError(w, http.StatusBadRequest, "draft_create_failed", err.Error(), nil) return } writeJSON(w, http.StatusCreated, draft) } func (s *Server) listDrafts(w http.ResponseWriter, r *http.Request) { drafts, err := s.svc.ListDrafts(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "draft_list_failed", err.Error(), nil) return } writeJSON(w, http.StatusOK, map[string]any{"drafts": drafts}) } func (s *Server) updateDraft(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } var input service.DraftInput if !decodeBody(w, r, &input) { return } draft, err := s.svc.UpdateDraft(r.Context(), id, input) if err != nil { handleServiceError(w, err) return } writeJSON(w, http.StatusOK, draft) } func (s *Server) sendDraft(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } outcome, err := s.svc.SendDraft(r.Context(), id) if err != nil { writeError(w, http.StatusBadGateway, "send_failed", err.Error(), outcome) return } writeJSON(w, http.StatusOK, outcome) } func (s *Server) sendDirect(w http.ResponseWriter, r *http.Request) { var input service.DraftInput if !decodeBody(w, r, &input) { return } outcome, err := s.svc.SendDirect(r.Context(), input) if err != nil { writeError(w, http.StatusBadGateway, "send_failed", err.Error(), outcome) return } writeJSON(w, http.StatusOK, outcome) } func (s *Server) forwardEmail(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } var input service.ForwardInput if !decodeBody(w, r, &input) { return } outcome, err := s.svc.ForwardEmail(r.Context(), id, input) if err != nil { handleServiceError(w, err) return } writeJSON(w, http.StatusOK, outcome) } func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool { defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(dst); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error(), nil) return false } return true } func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, "invalid_id", "id must be a positive integer", nil) return 0, false } return id, true } func intQuery(value string, def int) int { if value == "" { return def } n, err := strconv.Atoi(value) if err != nil { return def } return n } func boolQuery(value string) *bool { if value == "" { return nil } switch value { case "1", "true", "TRUE", "yes", "on": v := true return &v case "0", "false", "FALSE", "no", "off": v := false return &v default: return nil } } func handleServiceError(w http.ResponseWriter, err error) { switch { case errors.Is(err, service.ErrNotFound): writeError(w, http.StatusNotFound, "not_found", err.Error(), nil) case errors.Is(err, service.ErrAttachmentsForwardUnsupported): writeError(w, http.StatusBadRequest, "attachments_forward_unsupported", err.Error(), nil) default: writeError(w, http.StatusInternalServerError, "internal_error", err.Error(), nil) } } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeError(w http.ResponseWriter, status int, code, message string, details any) { writeJSON(w, status, map[string]any{ "error": map[string]any{ "code": code, "message": message, "details": details, }, }) } func withJSON(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") next.ServeHTTP(w, r) }) } func ListenAddr(cfg *config.Config) string { return fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port) }