// 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/ (FR-9). Best effort: the // reply is already sent, so a label failure is logged, not fatal. label, merr := s.actions.Move(ctx, msg, store.FolderReplied) if merr != nil { s.logger.Warn("move replied message failed", "message_id", id, "error", merr) s.logMessageAction(ctx, id, "reply_filed", "agent", "failed="+merr.Error()) } else if serr := s.store.SetMessageLocation(ctx, id, store.FolderReplied, label); serr != nil { s.logger.Error("record replied location", "message_id", id, "error", serr) } else { s.logMessageAction(ctx, id, "move", "Sam", fmt.Sprintf("to=%s label=%q from=%q", store.FolderReplied, label, msg.Mailbox)) } // Confirm the thread updated. This is read-only and best effort. threadUpdated := false if strings.TrimSpace(msg.ThreadID) != "" { if ok, verr := s.sender.VerifyThreadUpdated(ctx, msg.ThreadID); verr != nil { s.logger.Warn("verify thread update failed", "message_id", id, "error", verr) } else { threadUpdated = ok } } note := fmt.Sprintf("Reply sent to %s as %s.", to, out.From) if !threadUpdated && strings.TrimSpace(msg.ThreadID) != "" { note += " Could not yet confirm the thread update in Gmail; it may take a moment to appear." } s.renderMessage(w, r, id, note, "", true, threadUpdated) } // renderMessage loads and renders the message detail pane. func (s *Server) renderMessage(w http.ResponseWriter, r *http.Request, id int64, note, errMsg string, sent, threadUpdated bool) { ctx := r.Context() msg, err := s.store.MessageByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { http.Error(w, "message not found", http.StatusNotFound) return } if err != nil { s.logger.Error("load message detail", "message_id", id, "error", err) http.Error(w, "could not load message", http.StatusInternalServerError) return } class := s.latestClass(ctx, id) data := replyData{ Message: msg, Category: class.Category, Importance: class.Importance, Action: class.Action, Confidence: confidencePercent(class.Confidence), Classified: class.Category != "", Templates: reply.Templates, From: mailops.ReplyIdentity(msg, s.cfg.GmailUser), To: mailops.ReplyTo(msg), Subject: mailops.ReplySubject(msg.Subject), CSRFToken: s.csrfToken(w, r), Note: note, Error: errMsg, LLMReady: s.replies.Configured(), LLMModel: s.replies.Model(), Sent: sent, ThreadUpdated: threadUpdated, } if data.Action == "" { data.Action = "Review" } data.NoThreadHeader = strings.TrimSpace(msg.MessageIDHeader) == "" // FR-7: detect unsubscribe links and look up the domain's list status. if res := unsubscribe.Detect(msg.ListUnsubscribe, msg.ListUnsubscribePost, msg.FromAddr, msg.BodyText); len(res.Links) > 0 { data.UnsubLinks = res.Links if rec, ok, rerr := s.store.UnsubscribeByDomain(ctx, res.Links[0].Domain); rerr != nil { s.logger.Error("load unsubscribe status", "message_id", id, "error", rerr) } else if ok { data.UnsubStatus = rec.Status } } if d, ok, derr := s.store.LatestDraft(ctx, id); derr != nil { s.logger.Error("load draft", "message_id", id, "error", derr) } else if ok { data.HasDraft = true data.Draft = d templateID := d.TemplateID if templateID == "" { templateID = reply.TemplateForAction(class.Action).ID } data.TemplateID = templateID } if history, herr := s.store.MessageActions(ctx, id, 20); herr != nil { s.logger.Error("load message history", "message_id", id, "error", herr) } else { data.History = history } if !data.HasDraft { data.TemplateID = reply.TemplateForAction(class.Action).ID } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "message.html", data); err != nil { s.logger.Error("render message detail", "error", err) } } // latestClass returns the newest classification for a message. func (s *Server) latestClass(ctx context.Context, id int64) store.Classification { classes, err := s.store.LatestClassifications(ctx, []int64{id}) if err != nil { s.logger.Error("load latest classification", "message_id", id, "error", err) return store.Classification{} } return classes[id] } // parseMessageID reads the message id from the query or the posted form. func parseMessageID(r *http.Request) (int64, bool) { raw := strings.TrimSpace(r.FormValue("id")) if raw == "" { return 0, false } id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { return 0, false } return id, true } // Ensure the live implementations satisfy the interfaces at compile time. var ( _ ReplyGenerator = (*reply.Generator)(nil) _ MailSender = (*mailops.Client)(nil) )