Phase 5: reply drafts — template/LLM draft, edit, SMTP send (AUD-71)

Implements Phase 5 of AGENTS.md §11 (FR-8, FR-15):

- Message detail pane (GET /partials/message) with body, recommended
  action, reply identity, template picker, editable draft, and history.
- internal/reply: OmniRoute (OpenAI-compatible) chat client and a
  template-based draft generator. All non-JEV models go through
  OmniRoute (§10).
- internal/mailops: SMTP send over smtp.gmail.com:587 with STARTTLS and
  the app password, forcing IPv4 (§9); FR-15 reply identity (send as the
  arrived-at X-Pm-Original-To address, else "Sam Rolfe"); threading via
  In-Reply-To/References; -check-smtp safe verification; and a
  [Gmail]/Sent Mail thread check.
- Migration v5 stores messages.message_id_header and drafts.sent_at;
  mailfetch parses Message-ID.
- Send is user-triggered only: CSRF + UI confirm + confirm=yes guard,
  no auto-send, duplicate-send refused. A sent reply files the original
  under Replied-to/<address> and logs every step (FR-13).
- Tests for reply, mailops SMTP/identity/thread check, store drafts and
  migration v5, and the server draft/edit/send guards.
- docs/phase5-replies.md, README, .env.example, docker-compose updated.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip Builder
2026-10-11 22:05:35 +11:00
parent ae53374ad1
commit 7ca888f0c6
31 changed files with 2556 additions and 42 deletions

View File

@@ -21,6 +21,7 @@ import (
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/reply"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
@@ -43,6 +44,8 @@ type Server struct {
fetcher *mailfetch.Fetcher
classifier *classify.Classifier
actions MailActions
replies ReplyGenerator
sender MailSender
}
// New builds a Server. It does not start listening.
@@ -61,6 +64,7 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
return nil, fmt.Errorf("session manager: %w", err)
}
mail := mailops.New(cfg, logger)
s := &Server{
cfg: cfg,
store: st,
@@ -72,7 +76,12 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
),
actions: mailops.New(cfg, logger),
actions: mail,
sender: mail,
replies: reply.NewGenerator(
reply.NewClient(cfg.OmniRouteBaseURL, cfg.OmniRouteAPIKey, cfg.OmniRouteModel, logger),
logger,
),
}
s.http = &http.Server{
Addr: cfg.Addr,
@@ -128,6 +137,12 @@ func (s *Server) Handler() http.Handler {
// Phase 4 (FR-6/FR-9): bulk triage actions (delete, move, mark) and restore.
mux.HandleFunc("POST /actions/bulk", s.requireAuth(s.handleBulkAction))
// Phase 5 (FR-8/FR-15): message detail, reply draft, edit, and send.
mux.HandleFunc("GET /partials/message", s.requireAuth(s.handleMessageDetail))
mux.HandleFunc("POST /actions/draft", s.requireAuth(s.handleDraft))
mux.HandleFunc("POST /actions/draft/save", s.requireAuth(s.handleSaveDraft))
mux.HandleFunc("POST /actions/send", s.requireAuth(s.handleSendReply))
// FR-14: editable category list.
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))