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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user