Files
email_agent/internal/mailops/mailops.go
Sam Rolfe 8c0416750e Phase 4: triage actions (delete, move, mark; bulk select) (AUD-70)
Bulk select in the triage list, with delete/move/mark and a recoverable
delete (FR-6, FR-9).

- migration v4: messages.folder + messages.mailbox; folder/category views
- internal/mailops: app folder -> Gmail label moves over IMAP (IPv4), locate
  by X-GM-MSGID, create labels lazily, move/save/reply/restore, flag marks
- mailfetch: CREATE, UID MOVE (COPY+EXPUNGE fallback), STORE flags, X-GM-MSGID search
- server: POST /actions/bulk with CSRF, UI delete confirmation, second
  confirmation for messages classified Spam (never auto-deleted), per-message
  and summary action-log rows (FR-13)
- inbox: select-all toolbar, checkboxes, folder nav, Restore on non-inbox rows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 21:44:52 +11:00

247 lines
7.1 KiB
Go

// Package mailops performs the triage actions from Phase 4 over Gmail IMAP
// (FR-6, FR-9).
//
// The app's folders map to Gmail labels:
//
// inbox -> INBOX
// delete -> Delete (recoverable; never a hard delete)
// saved -> Saved
// replied -> Replied-to/<address> (FR-9, replied-to by address)
//
// Every connection forces IPv4, matching the read path (AGENTS.md §9).
package mailops
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"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/store"
)
// Label names for the app folders. "Delete" and "Saved" are ordinary Gmail
// labels created lazily on first use.
const (
LabelInbox = "INBOX"
LabelDelete = "Delete"
LabelSaved = "Saved"
repliedPrefix = "Replied-to/"
)
// ErrNotConfigured means Gmail credentials are missing, so no mailbox action can
// be attempted.
var ErrNotConfigured = errors.New("Gmail is not configured: set GMAIL_USER and GMAIL_APP_PASSWORD")
// Conn is the subset of the IMAP client the actions use. It lets the actions be
// tested without a live Gmail connection.
type Conn interface {
Login(user, pass string) error
Select(mailbox string) (uint32, error)
CreateMailbox(name string) error
Move(uid uint32, dest string) error
StoreFlags(uid uint32, op string, flag string) error
SearchGmailID(gmailID string) ([]uint32, error)
Logout() error
}
// dialFunc opens an IMAP connection. It is a field so tests can substitute one.
type dialFunc func(ctx context.Context, host string, port int, timeout time.Duration) (Conn, error)
// Client performs triage actions against Gmail IMAP.
type Client struct {
cfg *config.Config
logger *slog.Logger
dial dialFunc
}
// New builds a Client using the real Gmail IMAP dialer.
func New(cfg *config.Config, logger *slog.Logger) *Client {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &Client{cfg: cfg, logger: logger, dial: defaultDial}
}
// defaultDial dials Gmail over IPv4 (mailfetch.Dial forces tcp4).
func defaultDial(ctx context.Context, host string, port int, timeout time.Duration) (Conn, error) {
return mailfetch.Dial(ctx, host, port, timeout)
}
// Move files a message under destFolder and returns the Gmail label it now lives
// in. It creates the destination label when needed. A move to the folder the
// message is already in is a no-op.
func (c *Client) Move(ctx context.Context, msg store.Message, destFolder string) (string, error) {
dest := MailboxForFolder(destFolder, msg.SourceAddress)
source := msg.Mailbox
if strings.TrimSpace(source) == "" {
source = LabelInbox
}
if strings.EqualFold(dest, source) {
return dest, nil
}
conn, err := c.connect(ctx)
if err != nil {
return "", err
}
defer func() { _ = conn.Logout() }()
if !strings.EqualFold(dest, LabelInbox) {
if err := conn.CreateMailbox(dest); err != nil {
return "", fmt.Errorf("create label %q: %w", dest, err)
}
}
uidValidity, err := conn.Select(source)
if err != nil {
return "", fmt.Errorf("select %q: %w", source, err)
}
uid, err := locate(conn, msg, source, uidValidity)
if err != nil {
return "", err
}
if err := conn.Move(uid, dest); err != nil {
return "", fmt.Errorf("move message %s from %q to %q: %w", msg.GmailID, source, dest, err)
}
c.logger.Info("message moved",
"message_id", msg.ID, "gmail_id", msg.GmailID, "from", source, "to", dest)
return dest, nil
}
// Mark applies a read/star flag change to a message in its current mailbox.
// mark is one of read, unread, star, unstar.
func (c *Client) Mark(ctx context.Context, msg store.Message, mark string) error {
op, flag, err := markFlag(mark)
if err != nil {
return err
}
source := msg.Mailbox
if strings.TrimSpace(source) == "" {
source = LabelInbox
}
conn, err := c.connect(ctx)
if err != nil {
return err
}
defer func() { _ = conn.Logout() }()
uidValidity, err := conn.Select(source)
if err != nil {
return fmt.Errorf("select %q: %w", source, err)
}
uid, err := locate(conn, msg, source, uidValidity)
if err != nil {
return err
}
if err := conn.StoreFlags(uid, op, flag); err != nil {
return fmt.Errorf("mark message %s %s: %w", msg.GmailID, mark, err)
}
return nil
}
// connect dials and logs in. Callers must Logout when done.
func (c *Client) connect(ctx context.Context) (Conn, error) {
if !c.cfg.GmailConfigured() {
return nil, ErrNotConfigured
}
conn, err := c.dial(ctx, c.cfg.IMAPHost, c.cfg.IMAPPort, mailfetch.DefaultTimeout)
if err != nil {
return nil, err
}
if err := conn.Login(c.cfg.GmailUser, c.cfg.GmailAppPassword); err != nil {
_ = conn.Logout()
return nil, fmt.Errorf("imap login: %w", err)
}
return conn, nil
}
// locate finds a message's UID in the selected mailbox. Gmail gives each label
// its own UID, so the stable X-GM-MSGID is always used rather than a UID stored
// from a previous mailbox.
func locate(conn Conn, msg store.Message, source string, uidValidity uint32) (uint32, error) {
if !isGmailMessageID(msg.GmailID) {
return 0, fmt.Errorf("message %d has no Gmail message id; cannot locate it in %q", msg.ID, source)
}
uids, err := conn.SearchGmailID(msg.GmailID)
if err != nil {
return 0, fmt.Errorf("search %q for %s: %w", source, msg.GmailID, err)
}
if len(uids) == 0 {
return 0, fmt.Errorf("message %s is not in %q", msg.GmailID, source)
}
_ = uidValidity
return uids[len(uids)-1], nil
}
// isGmailMessageID reports whether id is a numeric X-GM-MSGID. Messages stored
// with the "uid:<validity>:<uid>" fallback cannot be searched across labels.
func isGmailMessageID(id string) bool {
id = strings.TrimSpace(id)
if id == "" {
return false
}
for _, r := range id {
if r < '0' || r > '9' {
return false
}
}
return true
}
// MailboxForFolder maps an app folder to a Gmail label. The replied folder uses
// the address the mail arrived at (FR-15), sanitised into a legal label.
func MailboxForFolder(folder, sourceAddress string) string {
switch folder {
case store.FolderDelete:
return LabelDelete
case store.FolderSaved:
return LabelSaved
case store.FolderReplied:
return repliedPrefix + SanitizeLabelAddress(sourceAddress)
default:
return LabelInbox
}
}
// SanitizeLabelAddress turns an email address into a Gmail-label-safe segment.
func SanitizeLabelAddress(addr string) string {
addr = strings.TrimSpace(addr)
addr = strings.Trim(addr, "<>")
addr = strings.ToLower(addr)
var b strings.Builder
for _, r := range addr {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9',
r == '@', r == '.', r == '_', r == '+', r == '-':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
out := b.String()
if out == "" {
return "unknown"
}
return out
}
// markFlag maps an action name to the IMAP flag operation.
func markFlag(mark string) (op, flag string, err error) {
switch strings.ToLower(strings.TrimSpace(mark)) {
case "read":
return "+", "\\Seen", nil
case "unread":
return "-", "\\Seen", nil
case "star":
return "+", "\\Flagged", nil
case "unstar":
return "-", "\\Flagged", nil
default:
return "", "", fmt.Errorf("unknown mark %q", mark)
}
}