Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions mailcmd/list.go
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions mailcmd/list_test.go
Original file line number Diff line number Diff line change
@@ -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.")
})
}
3 changes: 3 additions & 0 deletions mailcmd/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <message-id>
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())
Expand Down
Loading