package api import ( "encoding/json" "errors" "io" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/karti-ai/openmail/internal/core" "github.com/karti-ai/openmail/internal/mail" ) const maxIngestBytes = 30 << 20 // 30 MiB raw message cap func (s *Server) createInbox(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } var body struct { Address string `json:"address"` DisplayName *string `json:"display_name"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Address == "" { writeErr(w, http.StatusBadRequest, "invalid_request", "address is required") return } ib, err := s.core.CreateInbox(r.Context(), s.podID, body.Address, body.DisplayName) if handleErr(w, err) { return } writeJSON(w, http.StatusCreated, ib) } func (s *Server) listInboxes(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } list, err := s.core.ListInboxes(r.Context(), s.podID) if handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"inboxes": list}) } func (s *Server) getInbox(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } ib, err := s.core.GetInbox(r.Context(), chi.URLParam(r, "id")) if handleErr(w, err) { return } writeJSON(w, http.StatusOK, ib) } // ingest accepts a raw RFC 5322 message body and stores it in the inbox — the // milestone-1 seed path, and the same path a MailBackend uses for real inbound. func (s *Server) ingest(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } inboxID := chi.URLParam(r, "id") if _, err := s.core.GetInbox(r.Context(), inboxID); handleErr(w, err) { return } raw, err := io.ReadAll(io.LimitReader(r.Body, maxIngestBytes)) if err != nil || len(raw) == 0 { writeErr(w, http.StatusBadRequest, "invalid_request", "raw message body required") return } msg, err := s.core.IngestRaw(r.Context(), inboxID, raw) if handleErr(w, err) { return } writeJSON(w, http.StatusCreated, msg) } func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) list, err := s.core.ListMessages(r.Context(), chi.URLParam(r, "id"), limit) if handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"messages": list}) } func (s *Server) getMessage(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } m, err := s.core.GetMessage(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "msgID")) if handleErr(w, err) { return } writeJSON(w, http.StatusOK, m) } func (s *Server) listThreads(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } list, err := s.core.ListThreads(r.Context(), chi.URLParam(r, "id")) if handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"threads": list}) } func (s *Server) getThread(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } inboxID := chi.URLParam(r, "id") threadID := chi.URLParam(r, "threadID") t, err := s.core.GetThread(r.Context(), inboxID, threadID) if handleErr(w, err) { return } msgs, err := s.core.GetThreadMessages(r.Context(), inboxID, threadID) if handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"thread": t, "messages": msgs}) } func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } inboxID := chi.URLParam(r, "id") ib, err := s.core.GetInbox(r.Context(), inboxID) if handleErr(w, err) { return } var body struct { To []string `json:"to"` Cc []string `json:"cc"` Bcc []string `json:"bcc"` Subject string `json:"subject"` Text string `json:"text"` HTML string `json:"html"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.To) == 0 { writeErr(w, http.StatusBadRequest, "invalid_request", "at least one 'to' recipient is required") return } s.dispatch(w, r, &mail.OutgoingMessage{ InboxID: inboxID, From: ib.Address, To: body.To, Cc: body.Cc, Bcc: body.Bcc, Subject: body.Subject, Text: body.Text, HTML: body.HTML, }) } func (s *Server) replyMessage(w http.ResponseWriter, r *http.Request) { if !s.requireCore(w) { return } inboxID := chi.URLParam(r, "id") ib, err := s.core.GetInbox(r.Context(), inboxID) if handleErr(w, err) { return } orig, err := s.core.GetMessage(r.Context(), inboxID, chi.URLParam(r, "msgID")) if handleErr(w, err) { return } var body struct { Text string `json:"text"` HTML string `json:"html"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, http.StatusBadRequest, "invalid_request", "invalid body") return } out := &mail.OutgoingMessage{ InboxID: inboxID, From: ib.Address, Subject: replySubject(orig.Subject), Text: body.Text, HTML: body.HTML, References: append(append([]string{}, orig.References...), strPtr(orig.MessageIDHdr)...), } if orig.MessageIDHdr != nil { out.InReplyTo = *orig.MessageIDHdr } if orig.FromAddr != nil { out.To = []string{*orig.FromAddr} } s.dispatch(w, r, out) } // dispatch hands an outgoing message to the active backend, translating the // NullBackend's ErrNotSupported into a clear 503 (no send path configured yet). func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, out *mail.OutgoingMessage) { res, err := s.backend.Send(r.Context(), out) if errors.Is(err, mail.ErrNotSupported) { writeErr(w, http.StatusServiceUnavailable, "send_unavailable", "no send-capable mail backend configured (relay/imap_smtp/embedded land in later milestones)") return } if err != nil { writeErr(w, http.StatusBadGateway, "send_failed", err.Error()) return } writeJSON(w, http.StatusAccepted, res) } func handleErr(w http.ResponseWriter, err error) bool { var pgErr *pgconn.PgError switch { case err == nil: return false case errors.Is(err, core.ErrNotFound): writeErr(w, http.StatusNotFound, "not_found", "resource not found") case errors.As(err, &pgErr) && pgErr.Code == "22P02": // invalid_text_representation, e.g. a malformed UUID in the path — treat // as not found rather than a 500 that echoes the driver error. writeErr(w, http.StatusNotFound, "not_found", "resource not found") case errors.As(err, &pgErr) && pgErr.Code == "23505": // unique_violation, e.g. an inbox address that already exists. writeErr(w, http.StatusConflict, "conflict", "resource already exists") default: writeErr(w, http.StatusInternalServerError, "internal_error", "internal error") } return true } func replySubject(s *string) string { if s == nil || strings.TrimSpace(*s) == "" { return "Re:" } trimmed := strings.TrimSpace(*s) if strings.HasPrefix(strings.ToLower(trimmed), "re:") { return trimmed } return "Re: " + trimmed } func strPtr(s *string) []string { if s == nil || *s == "" { return nil } return []string{*s} }