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>
266 lines
8.3 KiB
Go
266 lines
8.3 KiB
Go
// 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
|
|
}
|