Implement internal mail skill MVP
This commit is contained in:
331
internal/api/api.go
Normal file
331
internal/api/api.go
Normal file
@@ -0,0 +1,331 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user