// Reply drafting and sending for Phase 5 (FR-8, FR-15). // // The flow is: open a message, generate a draft with the LLM (through // OmniRoute) from a template, edit it, then send it. Sending is always a // deliberate UI action (AGENTS.md ยง10): the handler requires a CSRF token and an // explicit confirmation, and it never auto-sends. A sent reply moves the // original to its Replied-to/
label and is logged (FR-13). package server import ( "context" "database/sql" "errors" "fmt" "net/http" "strconv" "strings" "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/store" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/unsubscribe" ) // ReplyGenerator drafts a reply body. The live implementation is // *reply.Generator; tests substitute a fake. type ReplyGenerator interface { Draft(ctx context.Context, req reply.Request) (body, templateID string, err error) Configured() bool Model() string } // MailSender sends a reply and can check that it joined the original thread. // The live implementation is *mailops.Client; tests substitute a fake. type MailSender interface { Send(ctx context.Context, msg mailops.OutgoingMessage) error VerifyThreadUpdated(ctx context.Context, threadID string) (bool, error) } // replyData is the template model for the message detail pane. type replyData struct { Message store.Message Category string Importance string Action string Confidence int Classified bool HasDraft bool Draft store.Draft Templates []reply.Template TemplateID string Instruction string From string To string Subject string CSRFToken string Note string Error string History []store.ActionLogEntry LLMReady bool LLMModel string // Phase 6 (FR-7): detected unsubscribe links for this message, and the // domain's status on the ignore/recorded list. UnsubLinks []unsubscribe.Link UnsubStatus string Sent bool ThreadUpdated bool RepliedFolder string // NoThreadHeader is true when the original message has no stored Message-ID, // so the reply cannot set In-Reply-To/References. NoThreadHeader bool } // handleMessageDetail renders the detail pane for one message. func (s *Server) handleMessageDetail(w http.ResponseWriter, r *http.Request) { id, ok := parseMessageID(r) if !ok { http.Error(w, "unknown message", http.StatusBadRequest) return } s.renderMessage(w, r, id, "", "", false, false) } // handleDraft generates a reply draft with the LLM and stores it. func (s *Server) handleDraft(w http.ResponseWriter, r *http.Request) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if err := r.ParseForm(); err != nil { http.Error(w, "could not read the form", http.StatusBadRequest) return } id, ok := parseMessageID(r) if !ok { http.Error(w, "unknown message", http.StatusBadRequest) return } if !s.replies.Configured() { s.renderMessage(w, r, id, "", "Reply drafting is not connected yet: set OMNIROUTE_API_KEY.", false, false) return } ctx := r.Context() msg, err := s.store.MessageByID(ctx, id) if err != nil { s.renderMessage(w, r, id, "", "Could not load that message.", false, false) return } class := s.latestClass(ctx, id) // FR-11: the reply model reads the current rules document too. rulesText, _, _ := s.store.CurrentRules(ctx) body, templateID, err := s.replies.Draft(ctx, reply.Request{ Message: msg, Category: class.Category, Importance: class.Importance, Action: class.Action, TemplateID: r.FormValue("template"), Instruction: r.FormValue("instruction"), Rules: rulesText, Signer: "Sam", }) if err != nil { s.logger.Warn("draft generation failed", "message_id", id, "error", err) s.renderMessage(w, r, id, "", "Draft generation failed: "+err.Error(), false, false) return } if _, err := s.store.SaveDraft(ctx, id, body, templateID); err != nil { s.logger.Error("save draft", "message_id", id, "error", err) s.renderMessage(w, r, id, "", "Could not save the draft.", false, false) return } s.logMessageAction(ctx, id, "draft", "Sam", fmt.Sprintf("template=%s model=%s chars=%d", templateID, s.replies.Model(), len(body))) s.renderMessage(w, r, id, fmt.Sprintf("Draft generated with the %q template. Edit it, then send.", templateID), "", false, false) } // handleSaveDraft stores Sam's edits to a draft. func (s *Server) handleSaveDraft(w http.ResponseWriter, r *http.Request) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if err := r.ParseForm(); err != nil { http.Error(w, "could not read the form", http.StatusBadRequest) return } id, ok := parseMessageID(r) if !ok { http.Error(w, "unknown message", http.StatusBadRequest) return } draftID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("draft_id")), 10, 64) if err != nil || draftID <= 0 { s.renderMessage(w, r, id, "", "There is no draft to save.", false, false) return } if err := s.store.UpdateDraftBody(r.Context(), draftID, r.FormValue("body")); err != nil { s.logger.Error("update draft", "draft_id", draftID, "error", err) s.renderMessage(w, r, id, "", "Could not save the draft.", false, false) return } s.logMessageAction(r.Context(), id, "draft_edit", "Sam", fmt.Sprintf("draft_id=%d", draftID)) s.renderMessage(w, r, id, "Draft saved.", "", false, false) } // handleSendReply sends the (edited) draft. It is the only handler that can send // mail, it needs the CSRF token and confirm=yes, and it never runs from a // schedule. func (s *Server) handleSendReply(w http.ResponseWriter, r *http.Request) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if err := r.ParseForm(); err != nil { http.Error(w, "could not read the form", http.StatusBadRequest) return } id, ok := parseMessageID(r) if !ok { http.Error(w, "unknown message", http.StatusBadRequest) return } if r.FormValue("confirm") != "yes" { s.renderMessage(w, r, id, "", "Sending needs confirmation. Press Send reply again and confirm.", false, false) return } ctx := r.Context() msg, err := s.store.MessageByID(ctx, id) if err != nil { s.renderMessage(w, r, id, "", "Could not load that message.", false, false) return } draftID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("draft_id")), 10, 64) if err != nil || draftID <= 0 { s.renderMessage(w, r, id, "", "There is no draft to send.", false, false) return } draft, err := s.store.DraftByID(ctx, draftID) if err != nil { s.renderMessage(w, r, id, "", "Could not load the draft.", false, false) return } if draft.Status == store.DraftStatusSent { s.renderMessage(w, r, id, "", "That draft has already been sent.", false, false) return } // Save any last edits before sending. body := strings.TrimSpace(r.FormValue("body")) if body == "" { body = draft.Body } if body == "" { s.renderMessage(w, r, id, "", "The reply is empty.", false, false) return } if body != draft.Body { if err := s.store.UpdateDraftBody(ctx, draftID, body); err != nil { s.logger.Error("update draft before send", "draft_id", draftID, "error", err) } } to := mailops.ReplyTo(msg) if to == "" { s.renderMessage(w, r, id, "", "This message has no sender address to reply to.", false, false) return } out := mailops.OutgoingMessage{ To: to, From: mailops.ReplyIdentity(msg, s.cfg.GmailUser), Subject: mailops.ReplySubject(msg.Subject), InReplyTo: msg.MessageIDHeader, References: msg.MessageIDHeader, Body: body, } if err := s.sender.Send(ctx, out); err != nil { s.logger.Error("send reply failed", "message_id", id, "to", to, "error", err) s.renderMessage(w, r, id, "", "Send failed: "+err.Error(), false, false) return } if err := s.store.MarkDraftSent(ctx, draftID); err != nil { s.logger.Error("mark draft sent", "draft_id", draftID, "error", err) } s.logMessageAction(ctx, id, "reply_sent", "Sam", fmt.Sprintf("to=%s from=%s template=%s", to, out.From, draft.TemplateID)) // File the original under Replied-to/