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>
This commit is contained in:
@@ -26,6 +26,11 @@ type Message struct {
|
||||
ReceivedAt time.Time
|
||||
IMAPUID int64
|
||||
IMAPUIDValidity int64
|
||||
// Folder is the app folder the message is filed under (Phase 4, FR-6/FR-9):
|
||||
// inbox, delete, saved, or replied.
|
||||
Folder string
|
||||
// Mailbox is the exact IMAP label the message currently lives in on Gmail.
|
||||
Mailbox string
|
||||
}
|
||||
|
||||
// MessageListItem is a message plus its latest classification, when present.
|
||||
@@ -38,6 +43,24 @@ type MessageListItem struct {
|
||||
HasClassification bool
|
||||
}
|
||||
|
||||
// App folders (Phase 4). A folder is a low-cardinality navigation concept; the
|
||||
// exact Gmail label is tracked separately on each message.
|
||||
const (
|
||||
FolderInbox = "inbox"
|
||||
FolderDelete = "delete"
|
||||
FolderSaved = "saved"
|
||||
FolderReplied = "replied"
|
||||
)
|
||||
|
||||
// ValidFolder reports whether folder is an app folder the UI knows how to show.
|
||||
func ValidFolder(folder string) bool {
|
||||
switch folder {
|
||||
case FolderInbox, FolderDelete, FolderSaved, FolderReplied:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpsertMessage inserts a message, or refreshes the mutable fields when the
|
||||
// same Gmail id is seen again. received_at is preserved on update so the
|
||||
// original arrival time is stable.
|
||||
@@ -99,6 +122,7 @@ SELECT
|
||||
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(c.category, ''), COALESCE(c.importance, ''),
|
||||
COALESCE(c.action, ''), COALESCE(c.confidence, 0)
|
||||
FROM messages m
|
||||
@@ -125,7 +149,7 @@ LIMIT ?`
|
||||
&item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr,
|
||||
&item.ToAddr, &item.Subject, &item.BodyText,
|
||||
&item.SourceAddress, &dateSent, &received,
|
||||
&item.IMAPUID, &item.IMAPUIDValidity,
|
||||
&item.IMAPUID, &item.IMAPUIDValidity, &item.Folder,
|
||||
&item.Category, &item.Importance, &item.Action, &item.Confidence,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan message: %w", err)
|
||||
|
||||
@@ -28,11 +28,18 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
-- belongs to. UIDs are only meaningful while UIDVALIDITY is unchanged.
|
||||
imap_uid INTEGER,
|
||||
imap_uidvalidity INTEGER,
|
||||
-- Phase 4 (FR-6/FR-9): the app folder a message is filed under (a low
|
||||
-- cardinality concept used for navigation) and the exact IMAP label it
|
||||
-- currently lives in on Gmail (for example "Delete", "Saved", or
|
||||
-- "Replied-to/hello@where-woof.com"). `folder` defaults to the inbox.
|
||||
folder TEXT NOT NULL DEFAULT 'inbox',
|
||||
mailbox TEXT NOT NULL DEFAULT 'INBOX',
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages (folder, received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages (thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_source ON messages (source_address);
|
||||
|
||||
|
||||
@@ -123,6 +123,27 @@ func (s *Store) applyMigrations(ctx context.Context) error {
|
||||
return fmt.Errorf("record schema version 3: %w", err)
|
||||
}
|
||||
}
|
||||
if version < 4 {
|
||||
// Phase 4 (FR-6/FR-9): triage actions file a message under an app folder
|
||||
// and remember the Gmail label it currently lives in, so a delete can be
|
||||
// undone by moving it back to the inbox.
|
||||
for _, col := range []struct{ table, name, typ string }{
|
||||
{"messages", "folder", "TEXT NOT NULL DEFAULT 'inbox'"},
|
||||
{"messages", "mailbox", "TEXT NOT NULL DEFAULT 'INBOX'"},
|
||||
} {
|
||||
if err := s.ensureColumn(ctx, col.table, col.name, col.typ); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages (folder, received_at)`); err != nil {
|
||||
return fmt.Errorf("index messages.folder: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO schema_migrations (version) VALUES (4)`); err != nil {
|
||||
return fmt.Errorf("record schema version 4: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
265
internal/store/triage.go
Normal file
265
internal/store/triage.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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
|
||||
}
|
||||
194
internal/store/triage_test.go
Normal file
194
internal/store/triage_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) (*Store, context.Context) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s, err := Open(ctx, filepath.Join(t.TempDir(), "email_agent.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s, ctx
|
||||
}
|
||||
|
||||
func seedMessage(t *testing.T, s *Store, ctx context.Context, gmailID, subject, source string) int64 {
|
||||
t.Helper()
|
||||
id, err := s.UpsertMessage(ctx, &Message{
|
||||
GmailID: gmailID,
|
||||
FromAddr: "sender@example.test",
|
||||
ToAddr: source,
|
||||
Subject: subject,
|
||||
SourceAddress: source,
|
||||
DateSent: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestMigrationAddsFolderColumns(t *testing.T) {
|
||||
s, ctx := testStore(t)
|
||||
|
||||
v, err := s.SchemaVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion() error = %v", err)
|
||||
}
|
||||
if v < 4 {
|
||||
t.Errorf("SchemaVersion() = %d, want >= 4", v)
|
||||
}
|
||||
for _, col := range []string{"folder", "mailbox"} {
|
||||
var found bool
|
||||
rows, err := s.DB().QueryContext(ctx, `SELECT name FROM pragma_table_info('messages')`)
|
||||
if err != nil {
|
||||
t.Fatalf("pragma_table_info: %v", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if name == col {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
if !found {
|
||||
t.Errorf("column %q missing after migration", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertDefaultsToInbox(t *testing.T) {
|
||||
s, ctx := testStore(t)
|
||||
seedMessage(t, s, ctx, "1", "hello", "a@x.test")
|
||||
|
||||
list, err := s.ListMessagesFiltered(ctx, MessageFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessagesFiltered() error = %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(list))
|
||||
}
|
||||
if list[0].Folder != FolderInbox {
|
||||
t.Errorf("folder = %q, want inbox", list[0].Folder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLocationAndFolderFilter(t *testing.T) {
|
||||
s, ctx := testStore(t)
|
||||
idA := seedMessage(t, s, ctx, "1", "keep me", "a@x.test")
|
||||
idB := seedMessage(t, s, ctx, "2", "delete me", "b@x.test")
|
||||
|
||||
if err := s.SetMessageLocation(ctx, idB, FolderDelete, "Delete"); err != nil {
|
||||
t.Fatalf("SetMessageLocation() error = %v", err)
|
||||
}
|
||||
|
||||
del, err := s.ListMessagesFiltered(ctx, MessageFilter{Folder: FolderDelete, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("delete filter: %v", err)
|
||||
}
|
||||
if len(del) != 1 || del[0].ID != idB {
|
||||
t.Fatalf("delete filter = %+v, want message %d", del, idB)
|
||||
}
|
||||
if del[0].Folder != FolderDelete {
|
||||
t.Errorf("folder = %q, want delete", del[0].Folder)
|
||||
}
|
||||
|
||||
inbox, err := s.ListMessagesFiltered(ctx, MessageFilter{Folder: FolderInbox, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("inbox filter: %v", err)
|
||||
}
|
||||
if len(inbox) != 1 || inbox[0].ID != idA {
|
||||
t.Fatalf("inbox filter = %+v, want message %d", inbox, idA)
|
||||
}
|
||||
|
||||
all, err := s.ListMessagesFiltered(ctx, MessageFilter{Folder: "all", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("all filter: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("all filter len = %d, want 2", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMessagesFilteredByCategory(t *testing.T) {
|
||||
s, ctx := testStore(t)
|
||||
idA := seedMessage(t, s, ctx, "1", "spammy", "a@x.test")
|
||||
idB := seedMessage(t, s, ctx, "2", "normal", "b@x.test")
|
||||
if _, err := s.SaveClassification(ctx, Classification{MessageID: idA, Category: "Spam", Importance: "high", Action: "delete", Confidence: 0.9}); err != nil {
|
||||
t.Fatalf("SaveClassification() error = %v", err)
|
||||
}
|
||||
|
||||
spam, err := s.ListMessagesFiltered(ctx, MessageFilter{Category: "spam", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("category filter: %v", err)
|
||||
}
|
||||
if len(spam) != 1 || spam[0].ID != idA {
|
||||
t.Fatalf("spam filter = %+v, want message %d", spam, idA)
|
||||
}
|
||||
|
||||
unclassified, err := s.ListMessagesFiltered(ctx, MessageFilter{Unclassified: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("unclassified filter: %v", err)
|
||||
}
|
||||
if len(unclassified) != 1 || unclassified[0].ID != idB {
|
||||
t.Fatalf("unclassified filter = %+v, want message %d", unclassified, idB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestClassifications(t *testing.T) {
|
||||
s, ctx := testStore(t)
|
||||
id := seedMessage(t, s, ctx, "1", "spammy", "a@x.test")
|
||||
if _, err := s.SaveClassification(ctx, Classification{MessageID: id, Category: "Info", Action: "keep"}); err != nil {
|
||||
t.Fatalf("first classification: %v", err)
|
||||
}
|
||||
if _, err := s.SaveClassification(ctx, Classification{MessageID: id, Category: "Spam", Action: "delete"}); err != nil {
|
||||
t.Fatalf("second classification: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.LatestClassifications(ctx, []int64{id})
|
||||
if err != nil {
|
||||
t.Fatalf("LatestClassifications() error = %v", err)
|
||||
}
|
||||
if got[id].Category != "Spam" {
|
||||
t.Errorf("category = %q, want the newest (Spam)", got[id].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountByFolderAndMessagesByIDs(t *testing.T) {
|
||||
s, ctx := testStore(t)
|
||||
idA := seedMessage(t, s, ctx, "1", "a", "a@x.test")
|
||||
idB := seedMessage(t, s, ctx, "2", "b", "b@x.test")
|
||||
if err := s.SetMessageLocation(ctx, idB, FolderSaved, "Saved"); err != nil {
|
||||
t.Fatalf("SetMessageLocation() error = %v", err)
|
||||
}
|
||||
|
||||
counts, err := s.CountByFolder(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountByFolder() error = %v", err)
|
||||
}
|
||||
if counts[FolderInbox] != 1 || counts[FolderSaved] != 1 {
|
||||
t.Errorf("counts = %v, want inbox:1 saved:1", counts)
|
||||
}
|
||||
|
||||
msgs, err := s.MessagesByIDs(ctx, []int64{idA, idB})
|
||||
if err != nil {
|
||||
t.Fatalf("MessagesByIDs() error = %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("MessagesByIDs() len = %d, want 2", len(msgs))
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.ID == idB && (m.Folder != FolderSaved || m.Mailbox != "Saved") {
|
||||
t.Errorf("saved message location = (%q, %q)", m.Folder, m.Mailbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user