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(" "); 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") } }