diff --git a/mailcmd/list.go b/mailcmd/list.go new file mode 100644 index 0000000..07598d5 --- /dev/null +++ b/mailcmd/list.go @@ -0,0 +1,87 @@ +package mailcmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// listQuery is the fixed Gmail query behind `mail list`: the inbox, newest +// first — what "list my mail" means to a human. Anything narrower or wider +// is `mail search`'s job. +const listQuery = "in:inbox" + +// newListCommand returns `mail list` — sibling symmetry with `calendar list`, +// `contacts list`, and `drive list`, all of which exist while mail previously +// offered only `search`. Without it, `mail list --max 5` failed with cobra's +// unhelpful `unknown flag: --max` (the parent `mail` command swallowed `list` +// as a positional arg and choked on the flag), which reads like a broken +// flag rather than a missing command. +func newListCommand() *cobra.Command { + var ( + maxResults int64 + idsOnly bool + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List recent inbox messages", + Long: `List the most recent messages in the inbox (equivalent to +mail search "in:inbox"). + +Use mail search for anything narrower or wider — archived mail, labels, +senders, date ranges: https://support.google.com/mail/answer/7190 + +Examples: + mail list + mail list --max 25 + mail list --ids | mail mark-read --stdin`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newGmailClient(cmd.Context()) + if err != nil { + return fmt.Errorf("creating Gmail client: %w", err) + } + + if idsOnly { + ids, err := client.SearchMessageIDs(cmd.Context(), listQuery, maxResults) + if err != nil { + return fmt.Errorf("listing messages: %w", err) + } + for _, id := range ids { + fmt.Println(id) + } + return nil + } + + messages, skipped, err := client.SearchMessages(cmd.Context(), listQuery, maxResults) + if err != nil { + return fmt.Errorf("listing messages: %w", err) + } + + if len(messages) == 0 { + fmt.Println("No messages found.") + return nil + } + + for _, msg := range messages { + printMessageHeader(msg, MessagePrintOptions{ + IncludeThreadID: true, + IncludeSnippet: true, + }) + fmt.Println("---") + } + + if skipped > 0 { + fmt.Printf("Note: %d message(s) could not be retrieved.\n", skipped) + } + + return nil + }, + } + + cmd.Flags().Int64VarP(&maxResults, "max", "m", 10, "Maximum number of results to return") + cmd.Flags().BoolVar(&idsOnly, "ids", false, "Output only message IDs (one per line, for piping)") + + return cmd +} diff --git a/mailcmd/list_test.go b/mailcmd/list_test.go new file mode 100644 index 0000000..142e03e --- /dev/null +++ b/mailcmd/list_test.go @@ -0,0 +1,106 @@ +package mailcmd + +import ( + "context" + "testing" + + gmailapi "github.com/open-cli-collective/google-cli-common/gmail" + "github.com/open-cli-collective/google-cli-common/testutil" +) + +func TestListCommand(t *testing.T) { + cmd := newListCommand() + + t.Run("has correct use", func(t *testing.T) { + testutil.Equal(t, cmd.Use, "list") + }) + + t.Run("takes no arguments", func(t *testing.T) { + err := cmd.Args(cmd, []string{}) + testutil.NoError(t, err) + + err = cmd.Args(cmd, []string{"unexpected"}) + testutil.Error(t, err) + }) + + // Flag parity with mail search: `mail search --max N` working while + // `mail list --max N` fails is exactly the divergence this command + // exists to close. + t.Run("has max flag matching search", func(t *testing.T) { + flag := cmd.Flags().Lookup("max") + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") + testutil.Equal(t, flag.DefValue, "10") + }) + + t.Run("has ids flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("ids") + testutil.NotNil(t, flag) + testutil.Equal(t, flag.DefValue, "false") + }) +} + +func TestListCommand_Success(t *testing.T) { + mock := &MockGmailClient{ + SearchMessagesFunc: func(_ context.Context, query string, maxResults int64) ([]*gmailapi.Message, int, error) { + testutil.Equal(t, query, listQuery) + testutil.Equal(t, maxResults, int64(10)) + return testutil.SampleMessages(2), 0, nil + }, + } + + cmd := newListCommand() + cmd.SetArgs([]string{}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + testutil.Contains(t, output, "ID: msg_a") + testutil.Contains(t, output, "ID: msg_b") + }) +} + +func TestListCommand_IDsOnly(t *testing.T) { + mock := &MockGmailClient{ + SearchMessageIDsFunc: func(_ context.Context, query string, maxResults int64) ([]string, error) { + testutil.Equal(t, query, listQuery) + testutil.Equal(t, maxResults, int64(25)) + return []string{"msg_a", "msg_b"}, nil + }, + } + + cmd := newListCommand() + cmd.SetArgs([]string{"--ids", "--max", "25"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + testutil.Equal(t, output, "msg_a\nmsg_b\n") + }) +} + +func TestListCommand_NoResults(t *testing.T) { + mock := &MockGmailClient{ + SearchMessagesFunc: func(_ context.Context, _ string, _ int64) ([]*gmailapi.Message, int, error) { + return []*gmailapi.Message{}, 0, nil + }, + } + + cmd := newListCommand() + cmd.SetArgs([]string{}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + testutil.Contains(t, output, "No messages found.") + }) +} diff --git a/mailcmd/mail.go b/mailcmd/mail.go index 6d14620..88fccd6 100644 --- a/mailcmd/mail.go +++ b/mailcmd/mail.go @@ -13,6 +13,7 @@ func NewCommand() *cobra.Command { Long: `Access to Gmail messages, threads, attachments, and organizational operations. This command group provides Gmail functionality: +- list: List recent inbox messages - search: Search for messages using Gmail query syntax - read: Read a single message - thread: Read a full conversation thread @@ -31,12 +32,14 @@ All organizational commands support bulk operations via positional IDs, --stdin (for piping), or --query (inline search). Examples: + mail list mail search "is:unread" mail read mail archive --query "from:noreply older_than:30d" mail search "is:inbox" --ids | mail star --stdin`, } + cmd.AddCommand(newListCommand()) cmd.AddCommand(newSearchCommand()) cmd.AddCommand(newReadCommand()) cmd.AddCommand(newThreadCommand())