// Folder and bulk-action persistence for Phase 4 (FR-6, FR-9, FR-13). // // A message carries two location fields after migration v4: `folder`, the // low-cardinality app view (inbox, delete, saved, replied), and `mailbox`, the // exact Gmail IMAP label it currently lives in. The mailbox is what a restore // needs, because a Gmail message gets a new UID in each label it appears in. package store import ( "context" "database/sql" "fmt" "strings" ) // UnclassifiedCategory is the triage bucket for messages whose latest // classification is missing. It is a view, not a stored category. const UnclassifiedCategory = "Unclassified" // MessageFilter narrows the triage list. An empty Folder means every folder; an // empty Category means every category. Unclassified selects messages with no // usable classification. type MessageFilter struct { Folder string Category string Unclassified bool Limit int } // ListMessagesFiltered returns messages matching the filter, newest first, // joined to their latest classification. It backs the inbox and folder views. func (s *Store) ListMessagesFiltered(ctx context.Context, f MessageFilter) ([]MessageListItem, error) { if f.Limit < 1 { f.Limit = 200 } var ( where []string args []any ) if f.Folder != "" && f.Folder != "all" { where = append(where, "m.folder = ?") args = append(args, f.Folder) } switch { case f.Unclassified: where = append(where, "(c.id IS NULL OR c.category = '')") case f.Category != "": where = append(where, "c.category = ? COLLATE NOCASE") args = append(args, f.Category) } clause := "" if len(where) > 0 { clause = "WHERE " + strings.Join(where, " AND ") } q := ` SELECT m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''), COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''), COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at, COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0), COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX'), COALESCE(c.category, ''), COALESCE(c.importance, ''), COALESCE(c.action, ''), COALESCE(c.confidence, 0) FROM messages m LEFT JOIN classifications c ON c.id = ( SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1 ) ` + clause + ` ORDER BY COALESCE(NULLIF(m.date_sent, ''), m.received_at) DESC, m.id DESC LIMIT ?` args = append(args, f.Limit) rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("list filtered messages: %w", err) } defer rows.Close() var out []MessageListItem for rows.Next() { var ( item MessageListItem dateSent string received string ) if err := rows.Scan( &item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr, &item.ToAddr, &item.Subject, &item.BodyText, &item.SourceAddress, &dateSent, &received, &item.IMAPUID, &item.IMAPUIDValidity, &item.Folder, &item.Mailbox, &item.Category, &item.Importance, &item.Action, &item.Confidence, ); err != nil { return nil, fmt.Errorf("scan filtered message: %w", err) } item.DateSent = parseSQLTime(dateSent) item.ReceivedAt = parseSQLTime(received) item.HasClassification = item.Category != "" out = append(out, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list filtered messages: %w", err) } return out, nil } // MessagesByIDs loads the messages with the given ids. Order is not significant. func (s *Store) MessagesByIDs(ctx context.Context, ids []int64) ([]Message, error) { if len(ids) == 0 { return nil, nil } placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } q := ` SELECT m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''), COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''), COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at, COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0), COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX') FROM messages m WHERE m.id IN (` + strings.Join(placeholders, ",") + `)` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("load messages by id: %w", err) } defer rows.Close() var out []Message for rows.Next() { var ( m Message dateSent string received string ) if err := rows.Scan( &m.ID, &m.GmailID, &m.ThreadID, &m.FromAddr, &m.ToAddr, &m.Subject, &m.BodyText, &m.SourceAddress, &dateSent, &received, &m.IMAPUID, &m.IMAPUIDValidity, &m.Folder, &m.Mailbox, ); err != nil { return nil, fmt.Errorf("scan message by id: %w", err) } m.DateSent = parseSQLTime(dateSent) m.ReceivedAt = parseSQLTime(received) out = append(out, m) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("load messages by id: %w", err) } return out, nil } // SetMessageLocation records the app folder and Gmail label a message now lives // in. It is called after a successful move; the database is only updated once // the mailbox agrees, so a failed move leaves the list truthful. func (s *Store) SetMessageLocation(ctx context.Context, id int64, folder, mailbox string) error { const q = `UPDATE messages SET folder = ?, mailbox = ? WHERE id = ?` if _, err := s.db.ExecContext(ctx, q, folder, mailbox, id); err != nil { return fmt.Errorf("set message %d location: %w", id, err) } return nil } // LatestClassifications returns the newest classification per message id. func (s *Store) LatestClassifications(ctx context.Context, ids []int64) (map[int64]Classification, error) { out := map[int64]Classification{} if len(ids) == 0 { return out, nil } placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } q := ` SELECT c.message_id, c.category, COALESCE(c.importance, ''), COALESCE(c.action, ''), COALESCE(c.confidence, 0), COALESCE(c.model, '') FROM classifications c WHERE c.id IN ( SELECT MAX(id) FROM classifications WHERE message_id IN (` + strings.Join(placeholders, ",") + `) GROUP BY message_id )` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("latest classifications: %w", err) } defer rows.Close() for rows.Next() { var c Classification if err := rows.Scan(&c.MessageID, &c.Category, &c.Importance, &c.Action, &c.Confidence, &c.Model); err != nil { return nil, fmt.Errorf("scan latest classification: %w", err) } out[c.MessageID] = c } if err := rows.Err(); err != nil { return nil, fmt.Errorf("latest classifications: %w", err) } return out, nil } // CountByFolder returns the number of messages in each app folder. func (s *Store) CountByFolder(ctx context.Context) (map[string]int, error) { rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(folder, 'inbox'), COUNT(*) FROM messages GROUP BY 1`) if err != nil { return nil, fmt.Errorf("count by folder: %w", err) } defer rows.Close() out := map[string]int{} for rows.Next() { var ( folder string n int ) if err := rows.Scan(&folder, &n); err != nil { return nil, fmt.Errorf("scan folder count: %w", err) } out[folder] = n } if err := rows.Err(); err != nil { return nil, fmt.Errorf("count by folder: %w", err) } return out, nil } // ActionLogEntry is one row of the append-only action log (FR-13). type ActionLogEntry struct { ID int64 MessageID sql.NullInt64 Action string Actor string Detail string CreatedAt string } // RecentActions returns the newest action-log rows, newest first. func (s *Store) RecentActions(ctx context.Context, limit int) ([]ActionLogEntry, error) { if limit < 1 { limit = 50 } rows, err := s.db.QueryContext(ctx, ` SELECT id, message_id, action, COALESCE(actor, ''), COALESCE(detail, ''), created_at FROM actions_log ORDER BY id DESC LIMIT ?`, limit) if err != nil { return nil, fmt.Errorf("recent actions: %w", err) } defer rows.Close() var out []ActionLogEntry for rows.Next() { var e ActionLogEntry if err := rows.Scan(&e.ID, &e.MessageID, &e.Action, &e.Actor, &e.Detail, &e.CreatedAt); err != nil { return nil, fmt.Errorf("scan action log: %w", err) } out = append(out, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("recent actions: %w", err) } return out, nil }