// 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/
(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) SearchGmailThreadID(threadID string) ([]uint32, error) // SentMailbox returns the server's \Sent mailbox (Gmail may call it // "[Gmail]/Sent Mail" or "[Google Mail]/Sent Mail"). SentMailbox() (string, 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 and sends replies over // Gmail SMTP. type Client struct { cfg *config.Config logger *slog.Logger dial dialFunc // smtpDial opens the SMTP connection (IPv4 only). It is a field so tests can // substitute one; the production value is dialSMTP4. smtpDial smtpDialFunc } // New builds a Client using the real Gmail IMAP dialer and IPv4 SMTP 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, smtpDial: dialSMTP4} } // 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 } // VerifyThreadUpdated reports whether the Gmail thread now contains a message in // the Sent mailbox. Phase 5 calls it after a reply is sent so the DoD "the // thread updates" can be checked without touching the recipient. func (c *Client) VerifyThreadUpdated(ctx context.Context, threadID string) (bool, error) { if strings.TrimSpace(threadID) == "" { return false, fmt.Errorf("message has no Gmail thread id") } conn, err := c.connect(ctx) if err != nil { return false, err } defer func() { _ = conn.Logout() }() sent, err := conn.SentMailbox() if err != nil { return false, fmt.Errorf("find the sent mailbox: %w", err) } if _, err := conn.Select(sent); err != nil { return false, fmt.Errorf("select %q: %w", sent, err) } uids, err := conn.SearchGmailThreadID(threadID) if err != nil { return false, fmt.Errorf("search thread %s in %q: %w", threadID, sent, err) } return len(uids) > 0, 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::" 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) } }