// Bulk triage actions for Phase 4 (FR-6, FR-9, FR-13). // // The UI selects rows in a group, then asks for delete, move, or mark. Two // guard rails matter here: // // - Deletes are recoverable: they move the message to the Delete label, never // a hard delete. A restore moves it back to INBOX. // - Spam is recommended, never auto-deleted. A delete that includes a message // classified Spam needs a second, explicit confirmation. // // Every action is appended to the action log. package server import ( "context" "fmt" "net/http" "strconv" "strings" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // MailActions is the mailbox side of a triage action. The live implementation is // *mailops.Client; tests substitute a fake. type MailActions interface { // Move files msg under destFolder and returns the Gmail label it now lives // in. Move(ctx context.Context, msg store.Message, destFolder string) (string, error) // Mark applies read/star flags (read, unread, star, unstar). Mark(ctx context.Context, msg store.Message, mark string) error } // handleBulkAction performs one action over the selected message ids. func (s *Server) handleBulkAction(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 { s.renderInbox(w, r, "", "Could not read the action form.") return } action := strings.ToLower(strings.TrimSpace(r.FormValue("action"))) ids := parseIDList(r.Form["ids"]) if len(ids) == 0 { s.renderInbox(w, r, "", "Select at least one message first.") return } ctx := r.Context() msgs, err := s.store.MessagesByIDs(ctx, ids) if err != nil { s.logger.Error("load selected messages", "error", err) s.renderInbox(w, r, "", "Could not load the selected messages.") return } if len(msgs) == 0 { s.renderInbox(w, r, "", "Those messages no longer exist.") return } // Bulk delete needs a confirmation (FR-6) and every spam action needs its // own confirmation (ยง10). The UI sets both after the user confirms; a // request that arrives without them is refused here. if action == "delete" { if r.FormValue("confirm") != "yes" { s.renderInbox(w, r, "", "Deleting needs confirmation. Select Delete again and confirm.") return } spam, err := s.spamCount(ctx, msgs) if err != nil { s.logger.Error("check spam before delete", "error", err) } if spam > 0 && r.FormValue("spam_confirm") != "yes" { s.renderInbox(w, r, "", fmt.Sprintf( "%d selected message(s) are classified Spam. Spam is never deleted automatically; confirm to move them to the Delete folder.", spam)) return } } actor := sessionFrom(r).User if actor == "" { actor = "Sam" } var ok, failed int for _, m := range msgs { var err error switch action { case "delete", "save", "reply", "restore": folder := folderForAction(action) var label string label, err = s.actions.Move(ctx, m, folder) if err == nil { if serr := s.store.SetMessageLocation(ctx, m.ID, folder, label); serr != nil { s.logger.Error("record message location", "message_id", m.ID, "error", serr) } s.logMessageAction(ctx, m.ID, "move", actor, fmt.Sprintf("to=%s label=%q from=%q", folder, label, m.Mailbox)) } case "mark_read", "mark_unread", "star", "unstar": mark := strings.TrimPrefix(action, "mark_") err = s.actions.Mark(ctx, m, mark) if err == nil { s.logMessageAction(ctx, m.ID, "mark", actor, "mark="+mark) } default: s.renderInbox(w, r, "", fmt.Sprintf("Unknown action %q.", action)) return } if err != nil { failed++ s.logger.Warn("triage action failed", "action", action, "message_id", m.ID, "error", err) continue } ok++ } s.logAction(ctx, "bulk_"+action, actor, fmt.Sprintf("requested=%d ok=%d failed=%d ids=%s", len(msgs), ok, failed, joinIDs(ids))) note := actionSummary(action, ok, failed) if failed > 0 { note += " " + s.failureHint() } s.renderInbox(w, r, note, "") } // spamCount reports how many of the selected messages are classified Spam. func (s *Server) spamCount(ctx context.Context, msgs []store.Message) (int, error) { ids := make([]int64, len(msgs)) for i, m := range msgs { ids[i] = m.ID } classes, err := s.store.LatestClassifications(ctx, ids) if err != nil { return 0, err } n := 0 for _, m := range msgs { if strings.EqualFold(strings.TrimSpace(classes[m.ID].Category), "Spam") { n++ } } return n, nil } // folderForAction maps a bulk action to its destination app folder. func folderForAction(action string) string { switch action { case "delete": return store.FolderDelete case "save": return store.FolderSaved case "reply": return store.FolderReplied default: return store.FolderInbox } } // actionSummary is the human note rendered above the refreshed list. func actionSummary(action string, ok, failed int) string { verb := map[string]string{ "delete": "Moved %d message(s) to the Delete folder (recoverable).", "save": "Saved %d message(s).", "reply": "Marked %d message(s) as replied-to.", "restore": "Restored %d message(s) to the inbox.", "mark_read": "Marked %d message(s) read.", "mark_unread": "Marked %d message(s) unread.", "star": "Starred %d message(s).", "unstar": "Unstarred %d message(s).", }[action] if verb == "" { verb = "Updated %d message(s)." } msg := fmt.Sprintf(verb, ok) if failed > 0 { msg += fmt.Sprintf(" %d failed.", failed) } return msg } // failureHint explains the common reason a mailbox action fails. func (s *Server) failureHint() string { return "Check GMAIL_USER, GMAIL_APP_PASSWORD, and the container log." } // logMessageAction records a per-message action; a logging failure must not fail // the request. func (s *Server) logMessageAction(ctx context.Context, messageID int64, action, actor, detail string) { if err := s.store.LogMessageAction(ctx, messageID, action, actor, detail); err != nil { s.logger.Warn("log message action", "message_id", messageID, "action", action, "error", err) } } // logAction records a summary action; a logging failure must not fail the // request. func (s *Server) logAction(ctx context.Context, action, actor, detail string) { if err := s.store.LogAction(ctx, action, actor, detail); err != nil { s.logger.Warn("log action", "action", action, "error", err) } } // parseIDList parses form values into unique, positive message ids. func parseIDList(values []string) []int64 { seen := map[int64]struct{}{} var out []int64 for _, v := range values { for _, part := range strings.Split(v, ",") { n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) if err != nil || n <= 0 { continue } if _, dup := seen[n]; dup { continue } seen[n] = struct{}{} out = append(out, n) } } return out } // joinIDs renders ids for an action-log detail line. func joinIDs(ids []int64) string { parts := make([]string, len(ids)) for i, id := range ids { parts[i] = strconv.FormatInt(id, 10) } return strings.Join(parts, ",") } // folderLabel is the display name for an app folder. func folderLabel(folder string) string { switch folder { case store.FolderDelete: return "Delete" case store.FolderSaved: return "Saved" case store.FolderReplied: return "Replied-to" case "all": return "All mail" default: return "Inbox" } } // Ensure the live client satisfies the interface at compile time. var _ MailActions = (*mailops.Client)(nil)