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:
114
internal/mailfetch/imap_write.go
Normal file
114
internal/mailfetch/imap_write.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// IMAP write operations for triage actions (Phase 4, FR-6/FR-9).
|
||||
//
|
||||
// The read path selects INBOX and fetches messages. The write path adds the
|
||||
// operations a move needs: create the destination label, locate a message in
|
||||
// the mailbox it currently lives in (Gmail gives each label its own UID, so the
|
||||
// stable X-GM-MSGID is used), move it, and set or clear flags.
|
||||
//
|
||||
// Deletes are never hard-deletes: a "delete" is a move to a recoverable label.
|
||||
package mailfetch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrMailboxExists is returned by CreateMailbox only when the server reports
|
||||
// something other than "already exists".
|
||||
var ErrMailboxExists = errors.New("mailbox already exists")
|
||||
|
||||
// CreateMailbox creates an IMAP mailbox (a Gmail label). An already-existing
|
||||
// mailbox is not an error: triage creates "Delete" and "Saved" lazily.
|
||||
func (c *Client) CreateMailbox(name string) error {
|
||||
err := c.exec("CREATE "+imapQuote(name), nil)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var se *ServerError
|
||||
if errors.As(err, &se) {
|
||||
upper := strings.ToUpper(se.Response)
|
||||
if strings.Contains(upper, "ALREADYEXISTS") ||
|
||||
strings.Contains(upper, "ALREADY EXISTS") ||
|
||||
strings.Contains(upper, "MAILBOX EXISTS") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Move moves a message to dest using the IMAP MOVE extension (RFC 6851, which
|
||||
// Gmail supports). If the server rejects MOVE, it falls back to COPY followed
|
||||
// by a source-side delete so the destination copy remains recoverable.
|
||||
func (c *Client) Move(uid uint32, dest string) error {
|
||||
id := strconv.FormatUint(uint64(uid), 10)
|
||||
if err := c.exec("UID MOVE "+id+" "+imapQuote(dest), nil); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
var se *ServerError
|
||||
if !errors.As(err, &se) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := c.exec("UID COPY "+id+" "+imapQuote(dest), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.exec("UID STORE "+id+" +FLAGS (\\Deleted)", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Expunge(uid)
|
||||
}
|
||||
|
||||
// Expunge removes a \Deleted message from the selected mailbox. UIDPLUS
|
||||
// (RFC 4315) keeps the expunge scoped to one UID; the plain EXPUNGE fallback is
|
||||
// only used by servers that do not advertise it.
|
||||
func (c *Client) Expunge(uid uint32) error {
|
||||
id := strconv.FormatUint(uint64(uid), 10)
|
||||
if err := c.exec("UID EXPUNGE "+id, nil); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
var se *ServerError
|
||||
if !errors.As(err, &se) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return c.exec("EXPUNGE", nil)
|
||||
}
|
||||
|
||||
// StoreFlags adds (op "+") or removes (op "-") message flags.
|
||||
func (c *Client) StoreFlags(uid uint32, op string, flag string) error {
|
||||
if op != "+" && op != "-" {
|
||||
return errors.New("flag operation must be + or -")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(uid), 10)
|
||||
return c.exec("UID STORE "+id+" "+op+"FLAGS ("+flag+")", nil)
|
||||
}
|
||||
|
||||
// SearchGmailID returns the UIDs of the messages with the given Gmail message
|
||||
// id (X-GM-MSGID) in the currently selected mailbox. Gmail assigns a different
|
||||
// UID per label, so this is how a message is located after a move.
|
||||
func (c *Client) SearchGmailID(gmailID string) ([]uint32, error) {
|
||||
gmailID = strings.TrimSpace(gmailID)
|
||||
if gmailID == "" {
|
||||
return nil, errors.New("empty x-gm-msgid")
|
||||
}
|
||||
for _, r := range gmailID {
|
||||
if r < '0' || r > '9' {
|
||||
return nil, errors.New("x-gm-msgid must be numeric")
|
||||
}
|
||||
}
|
||||
var uids []uint32
|
||||
err := c.exec("UID SEARCH X-GM-MSGID "+gmailID, func(resp *response) error {
|
||||
if !strings.HasPrefix(strings.ToUpper(resp.line), "* SEARCH") {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(resp.line)
|
||||
for _, f := range fields[2:] {
|
||||
if n, err := strconv.ParseUint(f, 10, 32); err == nil {
|
||||
uids = append(uids, uint32(n))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return uids, err
|
||||
}
|
||||
96
internal/mailfetch/imap_write_test.go
Normal file
96
internal/mailfetch/imap_write_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package mailfetch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateMailboxIgnoresAlreadyExists(t *testing.T) {
|
||||
c, _ := newFakeClient("A0001 NO [ALREADYEXISTS] Mailbox already exists\r\n")
|
||||
if err := c.CreateMailbox("Delete"); err != nil {
|
||||
t.Fatalf("CreateMailbox() error = %v, want nil for an existing mailbox", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMailboxReturnsOtherError(t *testing.T) {
|
||||
c, _ := newFakeClient("A0001 NO [CANNOT] permission denied\r\n")
|
||||
err := c.CreateMailbox("Delete")
|
||||
if err == nil {
|
||||
t.Fatal("CreateMailbox() error = nil, want failure")
|
||||
}
|
||||
var se *ServerError
|
||||
if !errors.As(err, &se) {
|
||||
t.Fatalf("CreateMailbox() error = %T, want *ServerError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveUsesMoveExtension(t *testing.T) {
|
||||
c, fc := newFakeClient("A0001 OK MOVE completed\r\n")
|
||||
if err := c.Move(5, "Delete"); err != nil {
|
||||
t.Fatalf("Move() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(fc.w.String(), `UID MOVE 5 "Delete"`) {
|
||||
t.Errorf("command = %q, want UID MOVE", fc.w.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFallsBackToCopyStoreExpunge(t *testing.T) {
|
||||
script := "A0001 NO [BAD] MOVE not supported\r\n" +
|
||||
"A0002 OK COPY completed\r\n" +
|
||||
"A0003 OK STORE completed\r\n" +
|
||||
"A0004 OK EXPUNGE completed\r\n"
|
||||
c, fc := newFakeClient(script)
|
||||
if err := c.Move(9, "Delete"); err != nil {
|
||||
t.Fatalf("Move() fallback error = %v", err)
|
||||
}
|
||||
got := fc.w.String()
|
||||
for _, want := range []string{
|
||||
`UID MOVE 9 "Delete"`,
|
||||
`UID COPY 9 "Delete"`,
|
||||
`UID STORE 9 +FLAGS (\Deleted)`,
|
||||
"UID EXPUNGE 9",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("writes = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFlags(t *testing.T) {
|
||||
c, fc := newFakeClient("A0001 OK STORE completed\r\n")
|
||||
if err := c.StoreFlags(4, "+", `\Seen`); err != nil {
|
||||
t.Fatalf("StoreFlags() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(fc.w.String(), `UID STORE 4 +FLAGS (\Seen)`) {
|
||||
t.Errorf("command = %q", fc.w.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFlagsRejectsBadOp(t *testing.T) {
|
||||
c, _ := newFakeClient("")
|
||||
if err := c.StoreFlags(4, "*", `\Seen`); err == nil {
|
||||
t.Fatal("StoreFlags() error = nil, want failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchGmailID(t *testing.T) {
|
||||
c, fc := newFakeClient("* SEARCH 3 4\r\nA0001 OK SEARCH completed\r\n")
|
||||
uids, err := c.SearchGmailID("1234000000000000000")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGmailID() error = %v", err)
|
||||
}
|
||||
if len(uids) != 2 || uids[0] != 3 || uids[1] != 4 {
|
||||
t.Fatalf("uids = %v, want [3 4]", uids)
|
||||
}
|
||||
if !strings.Contains(fc.w.String(), "UID SEARCH X-GM-MSGID 1234000000000000000") {
|
||||
t.Errorf("command = %q", fc.w.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchGmailIDRejectsNonNumeric(t *testing.T) {
|
||||
c, _ := newFakeClient("")
|
||||
if _, err := c.SearchGmailID(`1234") DELETE`); err == nil {
|
||||
t.Fatal("SearchGmailID() error = nil, want failure for a non-numeric id")
|
||||
}
|
||||
}
|
||||
246
internal/mailops/mailops.go
Normal file
246
internal/mailops/mailops.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
203
internal/mailops/mailops_test.go
Normal file
203
internal/mailops/mailops_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package mailops
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
type fakeConn struct {
|
||||
selected string
|
||||
uidValidity uint32
|
||||
created []string
|
||||
moves []string
|
||||
stores []string
|
||||
searched []string
|
||||
searchUIDs []uint32
|
||||
searchErr error
|
||||
loggedOut bool
|
||||
}
|
||||
|
||||
func (f *fakeConn) Login(string, string) error { return nil }
|
||||
func (f *fakeConn) Select(mailbox string) (uint32, error) {
|
||||
f.selected = mailbox
|
||||
return f.uidValidity, nil
|
||||
}
|
||||
func (f *fakeConn) CreateMailbox(name string) error {
|
||||
f.created = append(f.created, name)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeConn) Move(uid uint32, dest string) error {
|
||||
f.moves = append(f.moves, fmt.Sprintf("%d->%s", uid, dest))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeConn) StoreFlags(uid uint32, op, flag string) error {
|
||||
f.stores = append(f.stores, fmt.Sprintf("%d %s%s", uid, op, flag))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeConn) SearchGmailID(id string) ([]uint32, error) {
|
||||
f.searched = append(f.searched, id)
|
||||
return f.searchUIDs, f.searchErr
|
||||
}
|
||||
func (f *fakeConn) Logout() error { f.loggedOut = true; return nil }
|
||||
|
||||
func configured() *config.Config {
|
||||
return &config.Config{
|
||||
Env: "test",
|
||||
IMAPHost: config.DefaultIMAPHost,
|
||||
IMAPPort: config.DefaultIMAPPort,
|
||||
GmailUser: "user@example.test",
|
||||
GmailAppPassword: "app-password",
|
||||
}
|
||||
}
|
||||
|
||||
func newFakeClient(fc *fakeConn) *Client {
|
||||
c := New(configured(), slog.New(slog.DiscardHandler))
|
||||
c.dial = func(context.Context, string, int, time.Duration) (Conn, error) { return fc, nil }
|
||||
return c
|
||||
}
|
||||
|
||||
func TestMailboxForFolder(t *testing.T) {
|
||||
cases := []struct {
|
||||
folder string
|
||||
addr string
|
||||
want string
|
||||
}{
|
||||
{store.FolderInbox, "", "INBOX"},
|
||||
{store.FolderDelete, "", "Delete"},
|
||||
{store.FolderSaved, "", "Saved"},
|
||||
{store.FolderReplied, "Hello@Where-Woof.com", "Replied-to/hello@where-woof.com"},
|
||||
{store.FolderReplied, "", "Replied-to/unknown"},
|
||||
{"nonsense", "", "INBOX"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MailboxForFolder(tc.folder, tc.addr); got != tc.want {
|
||||
t.Errorf("MailboxForFolder(%q, %q) = %q, want %q", tc.folder, tc.addr, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLabelAddress(t *testing.T) {
|
||||
if got := SanitizeLabelAddress(" <Sam+news@Example.com> "); got != "sam+news@example.com" {
|
||||
t.Errorf("SanitizeLabelAddress() = %q", got)
|
||||
}
|
||||
if got := SanitizeLabelAddress(`weird"name@x`); got != "weird_name@x" {
|
||||
t.Errorf("SanitizeLabelAddress() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveDeleteCreatesLabelAndUsesGmailID(t *testing.T) {
|
||||
fc := &fakeConn{uidValidity: 7, searchUIDs: []uint32{42}}
|
||||
c := newFakeClient(fc)
|
||||
|
||||
label, err := c.Move(context.Background(), store.Message{
|
||||
ID: 1, GmailID: "1234000000000000000", Mailbox: "INBOX",
|
||||
}, store.FolderDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("Move() error = %v", err)
|
||||
}
|
||||
if label != "Delete" {
|
||||
t.Errorf("label = %q, want Delete", label)
|
||||
}
|
||||
if len(fc.created) != 1 || fc.created[0] != "Delete" {
|
||||
t.Errorf("created = %v, want [Delete]", fc.created)
|
||||
}
|
||||
if fc.selected != "INBOX" {
|
||||
t.Errorf("selected = %q, want INBOX", fc.selected)
|
||||
}
|
||||
if len(fc.moves) != 1 || fc.moves[0] != "42->Delete" {
|
||||
t.Errorf("moves = %v, want [42->Delete]", fc.moves)
|
||||
}
|
||||
if len(fc.searched) != 1 || fc.searched[0] != "1234000000000000000" {
|
||||
t.Errorf("searched = %v", fc.searched)
|
||||
}
|
||||
if !fc.loggedOut {
|
||||
t.Error("connection was not logged out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRestoreDoesNotCreateInbox(t *testing.T) {
|
||||
fc := &fakeConn{uidValidity: 7, searchUIDs: []uint32{9}}
|
||||
c := newFakeClient(fc)
|
||||
|
||||
label, err := c.Move(context.Background(), store.Message{
|
||||
ID: 2, GmailID: "555", Mailbox: "Delete",
|
||||
}, store.FolderInbox)
|
||||
if err != nil {
|
||||
t.Fatalf("Move() error = %v", err)
|
||||
}
|
||||
if label != "INBOX" {
|
||||
t.Errorf("label = %q, want INBOX", label)
|
||||
}
|
||||
if len(fc.created) != 0 {
|
||||
t.Errorf("created = %v, want none for INBOX", fc.created)
|
||||
}
|
||||
if fc.selected != "Delete" {
|
||||
t.Errorf("selected = %q, want Delete", fc.selected)
|
||||
}
|
||||
if len(fc.moves) != 1 || fc.moves[0] != "9->INBOX" {
|
||||
t.Errorf("moves = %v", fc.moves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNoopWhenAlreadyInFolder(t *testing.T) {
|
||||
fc := &fakeConn{}
|
||||
c := newFakeClient(fc)
|
||||
if _, err := c.Move(context.Background(), store.Message{ID: 3, GmailID: "1", Mailbox: "Saved"}, store.FolderSaved); err != nil {
|
||||
t.Fatalf("Move() error = %v", err)
|
||||
}
|
||||
if len(fc.moves) != 0 || len(fc.searched) != 0 {
|
||||
t.Errorf("no-op move did work: moves=%v searched=%v", fc.moves, fc.searched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRejectsNonGmailID(t *testing.T) {
|
||||
c := newFakeClient(&fakeConn{})
|
||||
_, err := c.Move(context.Background(), store.Message{ID: 4, GmailID: "uid:7:9", Mailbox: "INBOX"}, store.FolderDelete)
|
||||
if err == nil {
|
||||
t.Fatal("Move() error = nil, want failure for a uid: fallback id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNotConfigured(t *testing.T) {
|
||||
c := New(&config.Config{Env: "test", IMAPHost: config.DefaultIMAPHost, IMAPPort: config.DefaultIMAPPort}, nil)
|
||||
_, err := c.Move(context.Background(), store.Message{ID: 5, GmailID: "1", Mailbox: "INBOX"}, store.FolderDelete)
|
||||
if !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("Move() error = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkMapsFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
mark string
|
||||
want string
|
||||
}{
|
||||
{"read", "3 +\\Seen"},
|
||||
{"unread", "3 -\\Seen"},
|
||||
{"star", "3 +\\Flagged"},
|
||||
{"unstar", "3 -\\Flagged"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
fc := &fakeConn{uidValidity: 1, searchUIDs: []uint32{3}}
|
||||
c := newFakeClient(fc)
|
||||
if err := c.Mark(context.Background(), store.Message{ID: 6, GmailID: "777"}, tc.mark); err != nil {
|
||||
t.Fatalf("Mark(%q) error = %v", tc.mark, err)
|
||||
}
|
||||
if len(fc.stores) != 1 || fc.stores[0] != tc.want {
|
||||
t.Errorf("Mark(%q) stores = %v, want %q", tc.mark, fc.stores, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUnknown(t *testing.T) {
|
||||
c := newFakeClient(&fakeConn{})
|
||||
if err := c.Mark(context.Background(), store.Message{ID: 7, GmailID: "1"}, "bogus"); err == nil {
|
||||
t.Fatal("Mark(bogus) error = nil, want failure")
|
||||
}
|
||||
}
|
||||
255
internal/server/actions.go
Normal file
255
internal/server/actions.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Bulk triage actions for Phase 4 (FR-6, FR-9, FR-13).
|
||||
//
|
||||
// The UI selects rows in a group, then asks for delete, move, or mark. Two
|
||||
// guard rails matter here:
|
||||
//
|
||||
// - Deletes are recoverable: they move the message to the Delete label, never
|
||||
// a hard delete. A restore moves it back to INBOX.
|
||||
// - Spam is recommended, never auto-deleted. A delete that includes a message
|
||||
// classified Spam needs a second, explicit confirmation.
|
||||
//
|
||||
// Every action is appended to the action log.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// MailActions is the mailbox side of a triage action. The live implementation is
|
||||
// *mailops.Client; tests substitute a fake.
|
||||
type MailActions interface {
|
||||
// Move files msg under destFolder and returns the Gmail label it now lives
|
||||
// in.
|
||||
Move(ctx context.Context, msg store.Message, destFolder string) (string, error)
|
||||
// Mark applies read/star flags (read, unread, star, unstar).
|
||||
Mark(ctx context.Context, msg store.Message, mark string) error
|
||||
}
|
||||
|
||||
// handleBulkAction performs one action over the selected message ids.
|
||||
func (s *Server) handleBulkAction(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderInbox(w, r, "", "Could not read the action form.")
|
||||
return
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(r.FormValue("action")))
|
||||
ids := parseIDList(r.Form["ids"])
|
||||
if len(ids) == 0 {
|
||||
s.renderInbox(w, r, "", "Select at least one message first.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
msgs, err := s.store.MessagesByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
s.logger.Error("load selected messages", "error", err)
|
||||
s.renderInbox(w, r, "", "Could not load the selected messages.")
|
||||
return
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
s.renderInbox(w, r, "", "Those messages no longer exist.")
|
||||
return
|
||||
}
|
||||
|
||||
// Bulk delete needs a confirmation (FR-6) and every spam action needs its
|
||||
// own confirmation (§10). The UI sets both after the user confirms; a
|
||||
// request that arrives without them is refused here.
|
||||
if action == "delete" {
|
||||
if r.FormValue("confirm") != "yes" {
|
||||
s.renderInbox(w, r, "", "Deleting needs confirmation. Select Delete again and confirm.")
|
||||
return
|
||||
}
|
||||
spam, err := s.spamCount(ctx, msgs)
|
||||
if err != nil {
|
||||
s.logger.Error("check spam before delete", "error", err)
|
||||
}
|
||||
if spam > 0 && r.FormValue("spam_confirm") != "yes" {
|
||||
s.renderInbox(w, r, "", fmt.Sprintf(
|
||||
"%d selected message(s) are classified Spam. Spam is never deleted automatically; confirm to move them to the Delete folder.",
|
||||
spam))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
actor := sessionFrom(r).User
|
||||
if actor == "" {
|
||||
actor = "Sam"
|
||||
}
|
||||
|
||||
var ok, failed int
|
||||
for _, m := range msgs {
|
||||
var err error
|
||||
switch action {
|
||||
case "delete", "save", "reply", "restore":
|
||||
folder := folderForAction(action)
|
||||
var label string
|
||||
label, err = s.actions.Move(ctx, m, folder)
|
||||
if err == nil {
|
||||
if serr := s.store.SetMessageLocation(ctx, m.ID, folder, label); serr != nil {
|
||||
s.logger.Error("record message location", "message_id", m.ID, "error", serr)
|
||||
}
|
||||
s.logMessageAction(ctx, m.ID, "move", actor,
|
||||
fmt.Sprintf("to=%s label=%q from=%q", folder, label, m.Mailbox))
|
||||
}
|
||||
case "mark_read", "mark_unread", "star", "unstar":
|
||||
mark := strings.TrimPrefix(action, "mark_")
|
||||
err = s.actions.Mark(ctx, m, mark)
|
||||
if err == nil {
|
||||
s.logMessageAction(ctx, m.ID, "mark", actor, "mark="+mark)
|
||||
}
|
||||
default:
|
||||
s.renderInbox(w, r, "", fmt.Sprintf("Unknown action %q.", action))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
s.logger.Warn("triage action failed", "action", action, "message_id", m.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
|
||||
s.logAction(ctx, "bulk_"+action, actor,
|
||||
fmt.Sprintf("requested=%d ok=%d failed=%d ids=%s", len(msgs), ok, failed, joinIDs(ids)))
|
||||
|
||||
note := actionSummary(action, ok, failed)
|
||||
if failed > 0 {
|
||||
note += " " + s.failureHint()
|
||||
}
|
||||
s.renderInbox(w, r, note, "")
|
||||
}
|
||||
|
||||
// spamCount reports how many of the selected messages are classified Spam.
|
||||
func (s *Server) spamCount(ctx context.Context, msgs []store.Message) (int, error) {
|
||||
ids := make([]int64, len(msgs))
|
||||
for i, m := range msgs {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
classes, err := s.store.LatestClassifications(ctx, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if strings.EqualFold(strings.TrimSpace(classes[m.ID].Category), "Spam") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// folderForAction maps a bulk action to its destination app folder.
|
||||
func folderForAction(action string) string {
|
||||
switch action {
|
||||
case "delete":
|
||||
return store.FolderDelete
|
||||
case "save":
|
||||
return store.FolderSaved
|
||||
case "reply":
|
||||
return store.FolderReplied
|
||||
default:
|
||||
return store.FolderInbox
|
||||
}
|
||||
}
|
||||
|
||||
// actionSummary is the human note rendered above the refreshed list.
|
||||
func actionSummary(action string, ok, failed int) string {
|
||||
verb := map[string]string{
|
||||
"delete": "Moved %d message(s) to the Delete folder (recoverable).",
|
||||
"save": "Saved %d message(s).",
|
||||
"reply": "Marked %d message(s) as replied-to.",
|
||||
"restore": "Restored %d message(s) to the inbox.",
|
||||
"mark_read": "Marked %d message(s) read.",
|
||||
"mark_unread": "Marked %d message(s) unread.",
|
||||
"star": "Starred %d message(s).",
|
||||
"unstar": "Unstarred %d message(s).",
|
||||
}[action]
|
||||
if verb == "" {
|
||||
verb = "Updated %d message(s)."
|
||||
}
|
||||
msg := fmt.Sprintf(verb, ok)
|
||||
if failed > 0 {
|
||||
msg += fmt.Sprintf(" %d failed.", failed)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// failureHint explains the common reason a mailbox action fails.
|
||||
func (s *Server) failureHint() string {
|
||||
return "Check GMAIL_USER, GMAIL_APP_PASSWORD, and the container log."
|
||||
}
|
||||
|
||||
// logMessageAction records a per-message action; a logging failure must not fail
|
||||
// the request.
|
||||
func (s *Server) logMessageAction(ctx context.Context, messageID int64, action, actor, detail string) {
|
||||
if err := s.store.LogMessageAction(ctx, messageID, action, actor, detail); err != nil {
|
||||
s.logger.Warn("log message action", "message_id", messageID, "action", action, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// logAction records a summary action; a logging failure must not fail the
|
||||
// request.
|
||||
func (s *Server) logAction(ctx context.Context, action, actor, detail string) {
|
||||
if err := s.store.LogAction(ctx, action, actor, detail); err != nil {
|
||||
s.logger.Warn("log action", "action", action, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseIDList parses form values into unique, positive message ids.
|
||||
func parseIDList(values []string) []int64 {
|
||||
seen := map[int64]struct{}{}
|
||||
var out []int64
|
||||
for _, v := range values {
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[n]; dup {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// joinIDs renders ids for an action-log detail line.
|
||||
func joinIDs(ids []int64) string {
|
||||
parts := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
parts[i] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// folderLabel is the display name for an app folder.
|
||||
func folderLabel(folder string) string {
|
||||
switch folder {
|
||||
case store.FolderDelete:
|
||||
return "Delete"
|
||||
case store.FolderSaved:
|
||||
return "Saved"
|
||||
case store.FolderReplied:
|
||||
return "Replied-to"
|
||||
case "all":
|
||||
return "All mail"
|
||||
default:
|
||||
return "Inbox"
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the live client satisfies the interface at compile time.
|
||||
var _ MailActions = (*mailops.Client)(nil)
|
||||
303
internal/server/actions_test.go
Normal file
303
internal/server/actions_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
type moveCall struct {
|
||||
id int64
|
||||
dest string
|
||||
}
|
||||
|
||||
type markCall struct {
|
||||
id int64
|
||||
mark string
|
||||
}
|
||||
|
||||
type fakeActions struct {
|
||||
mu sync.Mutex
|
||||
moves []moveCall
|
||||
marks []markCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeActions) Move(_ context.Context, msg store.Message, dest string) (string, error) {
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.moves = append(f.moves, moveCall{id: msg.ID, dest: dest})
|
||||
return mailops.MailboxForFolder(dest, msg.SourceAddress), nil
|
||||
}
|
||||
|
||||
func (f *fakeActions) Mark(_ context.Context, msg store.Message, mark string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.marks = append(f.marks, markCall{id: msg.ID, mark: mark})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeActions) moveCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.moves)
|
||||
}
|
||||
|
||||
func (f *fakeActions) markCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.marks)
|
||||
}
|
||||
|
||||
func newActionsServer(t *testing.T) (*Server, *fakeActions) {
|
||||
t.Helper()
|
||||
srv := newTestServer(t)
|
||||
fa := &fakeActions{}
|
||||
srv.actions = fa
|
||||
return srv, fa
|
||||
}
|
||||
|
||||
func seedClassified(t *testing.T, srv *Server, gmailID, subject, category string) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
id, err := srv.store.UpsertMessage(ctx, &store.Message{
|
||||
GmailID: gmailID,
|
||||
FromAddr: "sender@example.test",
|
||||
ToAddr: "hello@where-woof.com",
|
||||
Subject: subject,
|
||||
SourceAddress: "hello@where-woof.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
if category != "" {
|
||||
if _, err := srv.store.SaveClassification(ctx, store.Classification{
|
||||
MessageID: id, Category: category, Importance: "high", Action: "delete", Confidence: 0.9,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed classification: %v", err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func bulkForm(csrf, action string, ids []int64, extra url.Values) url.Values {
|
||||
form := url.Values{"csrf": {csrf}, "action": {action}}
|
||||
for _, id := range ids {
|
||||
form.Add("ids", strconv.FormatInt(id, 10))
|
||||
}
|
||||
for k, v := range extra {
|
||||
form[k] = v
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
func TestBulkDeleteRequiresCSRF(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
seedClassified(t, srv, "1", "spammy", "Spam")
|
||||
|
||||
// No CSRF cookie and no csrf form value.
|
||||
form := url.Values{"action": {"delete"}, "ids": {"1"}, "confirm": {"yes"}, "spam_confirm": {"yes"}}
|
||||
rec := postForm(t, srv, "/actions/bulk", session, nil, form)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Errorf("moves = %d, want 0 on a rejected request", fa.moveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteRequiresConfirmation(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
id := seedClassified(t, srv, "1", "newsletter", "Newsletter")
|
||||
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Fatalf("moves = %d, want 0 without confirmation", fa.moveCount())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "needs confirmation") {
|
||||
t.Errorf("body did not explain the confirmation requirement")
|
||||
}
|
||||
|
||||
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
|
||||
if len(msgs) != 1 || msgs[0].Folder != store.FolderInbox {
|
||||
t.Errorf("message moved despite missing confirmation: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteSpamRequiresSpamConfirmation(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
id := seedClassified(t, srv, "1", "lottery winner", "Spam")
|
||||
|
||||
extra := url.Values{"confirm": {"yes"}} // no spam_confirm
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, extra))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Fatalf("moves = %d, want 0 without the spam confirmation", fa.moveCount())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "classified Spam") {
|
||||
t.Errorf("body did not explain the spam confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteThenRestoreIsRecoverable(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
spamID := seedClassified(t, srv, "1", "lottery winner", "Spam")
|
||||
infoID := seedClassified(t, srv, "2", "receipt", "Info")
|
||||
|
||||
extra := url.Values{"confirm": {"yes"}, "spam_confirm": {"yes"}}
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{spamID, infoID}, extra))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 2 {
|
||||
t.Fatalf("moves = %d, want 2", fa.moveCount())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Delete folder") {
|
||||
t.Errorf("note did not mention the Delete folder")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
deleted, err := srv.store.ListMessagesFiltered(ctx, store.MessageFilter{Folder: store.FolderDelete, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list delete folder: %v", err)
|
||||
}
|
||||
if len(deleted) != 2 {
|
||||
t.Fatalf("delete folder has %d messages, want 2", len(deleted))
|
||||
}
|
||||
for _, m := range deleted {
|
||||
if m.Mailbox != "Delete" {
|
||||
t.Errorf("message %d mailbox = %q, want Delete", m.ID, m.Mailbox)
|
||||
}
|
||||
}
|
||||
|
||||
// The Delete folder view shows the recoverable rows with a Restore button.
|
||||
view := getWithSession(t, srv, "/partials/inbox?folder=delete", session)
|
||||
if view.Code != http.StatusOK {
|
||||
t.Fatalf("delete folder view status = %d", view.Code)
|
||||
}
|
||||
if !strings.Contains(view.Body.String(), "Restore") {
|
||||
t.Errorf("delete folder view has no Restore button")
|
||||
}
|
||||
|
||||
// Restore one message.
|
||||
rec = postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "restore", []int64{spamID}, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore status = %d, want 200", rec.Code)
|
||||
}
|
||||
restored, err := srv.store.MessagesByIDs(ctx, []int64{spamID})
|
||||
if err != nil {
|
||||
t.Fatalf("load restored message: %v", err)
|
||||
}
|
||||
if len(restored) != 1 || restored[0].Folder != store.FolderInbox || restored[0].Mailbox != "INBOX" {
|
||||
t.Errorf("restored message = %+v, want inbox/INBOX", restored)
|
||||
}
|
||||
|
||||
// FR-13: every action has a log row.
|
||||
entries, err := srv.store.RecentActions(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentActions() error = %v", err)
|
||||
}
|
||||
var moves, bulk int
|
||||
for _, e := range entries {
|
||||
if e.Action == "move" {
|
||||
moves++
|
||||
}
|
||||
if strings.HasPrefix(e.Action, "bulk_") {
|
||||
bulk++
|
||||
}
|
||||
}
|
||||
if moves != 3 {
|
||||
t.Errorf("move log rows = %d, want 3 (2 deletes + 1 restore)", moves)
|
||||
}
|
||||
if bulk != 2 {
|
||||
t.Errorf("bulk summary rows = %d, want 2", bulk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxPartialHasSelectCheckboxes(t *testing.T) {
|
||||
srv, _ := newActionsServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
seedClassified(t, srv, "1", "one", "Info")
|
||||
seedClassified(t, srv, "2", "two", "Info")
|
||||
|
||||
rec := getWithSession(t, srv, "/partials/inbox", session)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `id="select-all"`) {
|
||||
t.Errorf("partial missing select-all checkbox")
|
||||
}
|
||||
if strings.Count(body, `name="ids"`) != 2 {
|
||||
t.Errorf("checkbox count = %d, want 2", strings.Count(body, `name="ids"`))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkMoveAndMark(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
id := seedClassified(t, srv, "1", "receipt", "Info")
|
||||
|
||||
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "save", []int64{id}, nil)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save status = %d", rec.Code)
|
||||
}
|
||||
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "mark_read", []int64{id}, nil)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("mark_read status = %d", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 1 || fa.markCount() != 1 {
|
||||
t.Fatalf("calls: moves=%d marks=%d, want 1 and 1", fa.moveCount(), fa.markCount())
|
||||
}
|
||||
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
|
||||
if len(msgs) != 1 || msgs[0].Folder != store.FolderSaved {
|
||||
t.Errorf("message folder = %+v, want saved", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkNoSelection(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", nil, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Select at least one") {
|
||||
t.Errorf("body did not explain the empty selection")
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Errorf("moves = %d, want 0", fa.moveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIDList(t *testing.T) {
|
||||
got := parseIDList([]string{"1,2", "2", "x", "-3", "4"})
|
||||
want := []int64{1, 2, 4}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parseIDList() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("parseIDList()[%d] = %d, want %d", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,9 @@ type navItem struct {
|
||||
Name string
|
||||
Color string
|
||||
Count int
|
||||
// Param is the /partials/inbox query fragment for this item, for example
|
||||
// "category=Action" or "folder=delete" (Phase 4 folder views).
|
||||
Param string
|
||||
}
|
||||
|
||||
type dashboardData struct {
|
||||
@@ -250,7 +253,7 @@ func categoryCounts(items []store.MessageListItem, categories []store.Category)
|
||||
return counts
|
||||
}
|
||||
|
||||
// handleDashboard renders the inbox shell with real category counts.
|
||||
// handleDashboard renders the inbox shell with real category and folder counts.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFrom(r)
|
||||
|
||||
@@ -266,15 +269,26 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
s.logger.Error("load dashboard categories", "error", err)
|
||||
}
|
||||
counts := categoryCounts(items, categories)
|
||||
folderCounts, err := s.store.CountByFolder(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("load dashboard folder counts", "error", err)
|
||||
folderCounts = nil
|
||||
}
|
||||
|
||||
catNav := make([]navItem, 0, len(categories)+1)
|
||||
for _, c := range categories {
|
||||
catNav = append(catNav, navItem{Name: c.Name, Color: "var(--primary-soft, var(--surface-2))", Count: counts[c.Name]})
|
||||
catNav = append(catNav, navItem{
|
||||
Name: c.Name,
|
||||
Color: "var(--primary-soft, var(--surface-2))",
|
||||
Count: counts[c.Name],
|
||||
Param: "category=" + url.QueryEscape(c.Name),
|
||||
})
|
||||
}
|
||||
catNav = append(catNav, navItem{
|
||||
Name: unclassifiedCategory,
|
||||
Color: "var(--ink-faint)",
|
||||
Count: counts[unclassifiedCategory],
|
||||
Param: "category=" + url.QueryEscape(unclassifiedCategory),
|
||||
})
|
||||
|
||||
data := dashboardData{
|
||||
@@ -285,9 +299,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
Total: len(items),
|
||||
Categories: catNav,
|
||||
Folders: []navItem{
|
||||
{Name: "Replied-to", Color: "var(--sticker-green)"},
|
||||
{Name: "Delete", Color: "var(--sticker-red)"},
|
||||
{Name: "Saved", Color: "var(--sticker-teal)"},
|
||||
{Name: "Replied-to", Color: "var(--sticker-green)", Count: folderCounts[store.FolderReplied], Param: "folder=" + store.FolderReplied},
|
||||
{Name: "Delete", Color: "var(--sticker-red)", Count: folderCounts[store.FolderDelete], Param: "folder=" + store.FolderDelete},
|
||||
{Name: "Saved", Color: "var(--sticker-teal)", Count: folderCounts[store.FolderSaved], Param: "folder=" + store.FolderSaved},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Inbox rendering, the manual mail scan, and the classify action.
|
||||
//
|
||||
// Phase 3 groups messages by the live category list (FR-5) and shows the JEV
|
||||
// recommended action plus confidence on each row. Messages without a
|
||||
// classification fall under "Unclassified".
|
||||
// recommended action plus confidence on each row. Phase 4 adds a bulk-select
|
||||
// toolbar (FR-6) and folder views so a recoverable delete can be seen under the
|
||||
// Delete folder and restored.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
|
||||
const unclassifiedCategory = "Unclassified"
|
||||
const unclassifiedCategory = store.UnclassifiedCategory
|
||||
|
||||
type messageRow struct {
|
||||
ID int64
|
||||
@@ -31,6 +33,9 @@ type messageRow struct {
|
||||
Confidence int
|
||||
Classified bool
|
||||
When string
|
||||
// Restorable is true for a message outside the inbox, so the row offers a
|
||||
// Restore button (FR-9: deletes are recoverable).
|
||||
Restorable bool
|
||||
}
|
||||
|
||||
type messageGroup struct {
|
||||
@@ -39,15 +44,20 @@ type messageGroup struct {
|
||||
}
|
||||
|
||||
type inboxData struct {
|
||||
Groups []messageGroup
|
||||
Total int
|
||||
Note string
|
||||
Error string
|
||||
Groups []messageGroup
|
||||
Total int
|
||||
Note string
|
||||
Error string
|
||||
CSRFToken string
|
||||
Folder string
|
||||
Category string
|
||||
FolderLabel string
|
||||
}
|
||||
|
||||
// loadInbox reads messages and groups them by the live category list.
|
||||
func (s *Server) loadInbox(ctx context.Context) (inboxData, error) {
|
||||
items, err := s.store.ListMessages(ctx, 200)
|
||||
// loadInbox reads messages for the active folder/category filter and groups them
|
||||
// by the live category list.
|
||||
func (s *Server) loadInbox(ctx context.Context, f store.MessageFilter) (inboxData, error) {
|
||||
items, err := s.store.ListMessagesFiltered(ctx, f)
|
||||
if err != nil {
|
||||
return inboxData{}, err
|
||||
}
|
||||
@@ -88,6 +98,10 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
||||
if action == "" {
|
||||
action = "Review"
|
||||
}
|
||||
folder := strings.TrimSpace(it.Folder)
|
||||
if folder == "" {
|
||||
folder = store.FolderInbox
|
||||
}
|
||||
buckets[key] = append(buckets[key], messageRow{
|
||||
ID: it.ID,
|
||||
From: orDefault(it.FromAddr, "(unknown sender)"),
|
||||
@@ -97,6 +111,7 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
||||
Confidence: confidencePercent(it.Confidence),
|
||||
Classified: it.HasClassification,
|
||||
When: formatWhen(it.DateSent, it.ReceivedAt),
|
||||
Restorable: folder != store.FolderInbox,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,6 +141,20 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
||||
return groups
|
||||
}
|
||||
|
||||
// inboxSelection reads the active folder/category from the query or the posted
|
||||
// form, applying the inbox default and rejecting unknown values.
|
||||
func inboxSelection(r *http.Request) (folder, category string) {
|
||||
folder = strings.TrimSpace(r.FormValue("folder"))
|
||||
category = strings.TrimSpace(r.FormValue("category"))
|
||||
if folder == "" {
|
||||
folder = store.FolderInbox
|
||||
}
|
||||
if folder != "all" && !store.ValidFolder(folder) {
|
||||
folder = store.FolderInbox
|
||||
}
|
||||
return folder, category
|
||||
}
|
||||
|
||||
// confidencePercent turns a 0–1 confidence into a whole percentage.
|
||||
func confidencePercent(v float64) int {
|
||||
if v <= 0 {
|
||||
@@ -137,14 +166,28 @@ func confidencePercent(v float64) int {
|
||||
return int(v*100 + 0.5)
|
||||
}
|
||||
|
||||
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
|
||||
data, err := s.loadInbox(r.Context())
|
||||
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note, errMsg string) {
|
||||
folder, category := inboxSelection(r)
|
||||
data, err := s.loadInbox(r.Context(), store.MessageFilter{
|
||||
Folder: folder,
|
||||
Category: category,
|
||||
Unclassified: strings.EqualFold(category, unclassifiedCategory),
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("load inbox", "error", err)
|
||||
http.Error(w, "could not load messages", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.Note = note
|
||||
data.Error = errMsg
|
||||
data.CSRFToken = s.csrfToken(w, r)
|
||||
data.Folder = folder
|
||||
data.Category = category
|
||||
data.FolderLabel = folderLabel(folder)
|
||||
if category != "" {
|
||||
data.FolderLabel = orDefault(category, data.FolderLabel)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "inbox.html", data); err != nil {
|
||||
s.logger.Error("render inbox partial", "error", err)
|
||||
@@ -153,7 +196,7 @@ func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string
|
||||
|
||||
// handleInboxPartial is the HTMX fragment for the centre list.
|
||||
func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderInbox(w, r, "")
|
||||
s.renderInbox(w, r, "", "")
|
||||
}
|
||||
|
||||
// handleFetchMail runs one IMAP scan, then returns the refreshed list. It is a
|
||||
@@ -170,14 +213,14 @@ func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) {
|
||||
sum, err := s.fetcher.Fetch(ctx)
|
||||
switch {
|
||||
case err == nil:
|
||||
s.renderInbox(w, r, fmt.Sprintf("Scanned %d message(s); %d stored or refreshed.", sum.Fetched, sum.Stored))
|
||||
s.renderInbox(w, r, fmt.Sprintf("Scanned %d message(s); %d stored or refreshed.", sum.Fetched, sum.Stored), "")
|
||||
case errors.Is(err, mailfetch.ErrNoCredentials):
|
||||
s.renderInbox(w, r, "Mail is not connected yet: set GMAIL_USER and GMAIL_APP_PASSWORD.")
|
||||
s.renderInbox(w, r, "Mail is not connected yet: set GMAIL_USER and GMAIL_APP_PASSWORD.", "")
|
||||
case errors.Is(err, mailfetch.ErrAlreadyRunning):
|
||||
s.renderInbox(w, r, "A mail scan is already running.")
|
||||
s.renderInbox(w, r, "A mail scan is already running.", "")
|
||||
default:
|
||||
s.logger.Error("mail scan failed", "error", err)
|
||||
s.renderInbox(w, r, "Mail scan failed: "+err.Error())
|
||||
s.renderInbox(w, r, "Mail scan failed: "+err.Error(), "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,12 +262,12 @@ func (s *Server) runClassifier(w http.ResponseWriter, r *http.Request, all bool)
|
||||
case err == nil:
|
||||
s.renderInbox(w, r, fmt.Sprintf(
|
||||
"Classified %d of %d message(s); %d failed; %d still unclassified.",
|
||||
sum.Classified, sum.Considered, sum.Failed, sum.Remaining))
|
||||
sum.Classified, sum.Considered, sum.Failed, sum.Remaining), "")
|
||||
case errors.Is(err, classify.ErrNotConfigured):
|
||||
s.renderInbox(w, r, "Classification is not connected yet: set JEV_API_KEY.")
|
||||
s.renderInbox(w, r, "Classification is not connected yet: set JEV_API_KEY.", "")
|
||||
default:
|
||||
s.logger.Error("classification run failed", "error", err)
|
||||
s.renderInbox(w, r, "Classification failed: "+err.Error())
|
||||
s.renderInbox(w, r, "Classification failed: "+err.Error(), "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,3 +301,18 @@ func formatWhen(sent, received time.Time) string {
|
||||
}
|
||||
return t.Format("2 Jan 2006")
|
||||
}
|
||||
|
||||
// inboxURL builds the /partials/inbox URL for a folder/category view.
|
||||
func inboxURL(folder, category string) string {
|
||||
q := url.Values{}
|
||||
if folder != "" {
|
||||
q.Set("folder", folder)
|
||||
}
|
||||
if category != "" {
|
||||
q.Set("category", category)
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return "/partials/inbox"
|
||||
}
|
||||
return "/partials/inbox?" + q.Encode()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
|
||||
"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/mailops"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
|
||||
@@ -41,6 +42,7 @@ type Server struct {
|
||||
sessions *session.Manager
|
||||
fetcher *mailfetch.Fetcher
|
||||
classifier *classify.Classifier
|
||||
actions MailActions
|
||||
}
|
||||
|
||||
// New builds a Server. It does not start listening.
|
||||
@@ -70,6 +72,7 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
|
||||
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
|
||||
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
|
||||
),
|
||||
actions: mailops.New(cfg, logger),
|
||||
}
|
||||
s.http = &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
@@ -122,6 +125,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /actions/classify", s.requireAuth(s.handleClassify))
|
||||
mux.HandleFunc("POST /actions/reclassify", s.requireAuth(s.handleReclassify))
|
||||
|
||||
// Phase 4 (FR-6/FR-9): bulk triage actions (delete, move, mark) and restore.
|
||||
mux.HandleFunc("POST /actions/bulk", s.requireAuth(s.handleBulkAction))
|
||||
|
||||
// FR-14: editable category list.
|
||||
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
|
||||
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))
|
||||
|
||||
@@ -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