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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Add `--format text` to any command for human-readable output.

| Command | Description |
|---------|-------------|
| `gws gmail list` | List threads with `thread_id` and `message_id` (`--max`, `--query`) |
| `gws gmail list` | List threads with `thread_id` and `message_id` (`--max`, `--query`, `--all` for pagination) |
| `gws gmail read <id>` | Read message body and headers |
| `gws gmail thread <id>` | Read full thread conversation |
| `gws gmail send` | Send email (`--to`, `--subject`, `--body`, `--cc`, `--bcc`, `--thread-id`, `--reply-to-message-id`) |
Expand Down
69 changes: 59 additions & 10 deletions cmd/gmail.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ func init() {
gmailCmd.AddCommand(gmailReplyCmd)

// List flags
gmailListCmd.Flags().Int64("max", 10, "Maximum number of results")
gmailListCmd.Flags().Int64("max", 10, "Maximum number of results (use --all for unlimited)")
gmailListCmd.Flags().String("query", "", "Gmail search query (e.g., 'is:unread', 'from:someone@example.com')")
gmailListCmd.Flags().Bool("all", false, "Fetch all matching results (may take time for large result sets)")

// Send flags
gmailSendCmd.Flags().String("to", "", "Recipient email address (required)")
Expand Down Expand Up @@ -202,21 +203,69 @@ func runGmailList(cmd *cobra.Command, args []string) error {

maxResults, _ := cmd.Flags().GetInt64("max")
query, _ := cmd.Flags().GetString("query")
fetchAll, _ := cmd.Flags().GetBool("all")

// Gmail API has a hard limit of 500 results per request
const apiMaxPerPage int64 = 500

// Collect all threads using pagination
var allThreads []*gmail.Thread
var pageToken string
pageNum := 1

for {
// Determine how many to fetch in this request
perPage := apiMaxPerPage
if !fetchAll && maxResults > 0 {
remaining := maxResults - int64(len(allThreads))
if remaining <= 0 {
break
}
if remaining < perPage {
perPage = remaining
}
}

call := svc.Users.Threads.List("me").MaxResults(perPage)
if query != "" {
call = call.Q(query)
}
if pageToken != "" {
call = call.PageToken(pageToken)
}

resp, err := call.Do()
if err != nil {
return p.PrintError(fmt.Errorf("failed to list threads: %w", err))
}

// List threads (more useful than individual messages)
call := svc.Users.Threads.List("me").MaxResults(maxResults)
if query != "" {
call = call.Q(query)
allThreads = append(allThreads, resp.Threads...)

// Progress indicator for multi-page fetches (to stderr)
if resp.NextPageToken != "" && (fetchAll || maxResults > apiMaxPerPage) {
fmt.Fprintf(os.Stderr, "Fetched page %d (%d threads so far)...\n", pageNum, len(allThreads))
}

// Check if we should continue
if resp.NextPageToken == "" {
break
}
if !fetchAll && int64(len(allThreads)) >= maxResults {
break
}

pageToken = resp.NextPageToken
pageNum++
}

resp, err := call.Do()
if err != nil {
return p.PrintError(fmt.Errorf("failed to list threads: %w", err))
// Trim to max if we fetched more (can happen due to page boundaries)
if !fetchAll && maxResults > 0 && int64(len(allThreads)) > maxResults {
allThreads = allThreads[:maxResults]
}

// Format results
results := make([]map[string]interface{}, 0, len(resp.Threads))
for _, thread := range resp.Threads {
results := make([]map[string]interface{}, 0, len(allThreads))
for _, thread := range allThreads {
// Get thread details for snippet and subject
threadDetail, err := svc.Users.Threads.Get("me", thread.Id).Format("metadata").MetadataHeaders("Subject", "From", "Date").Do()
if err != nil {
Expand Down
175 changes: 175 additions & 0 deletions cmd/gmail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1239,3 +1239,178 @@ func TestGmailLabel_OutputFormat(t *testing.T) {
t.Errorf("unexpected message_id: %v", decoded["message_id"])
}
}

// TestGmailListCommand_AllFlag tests that the --all flag exists
func TestGmailListCommand_AllFlag(t *testing.T) {
cmd := gmailListCmd

allFlag := cmd.Flags().Lookup("all")
if allFlag == nil {
t.Error("expected --all flag to exist")
}
if allFlag.DefValue != "false" {
t.Errorf("expected --all default 'false', got '%s'", allFlag.DefValue)
}
}

// TestGmailList_Pagination_MockServer tests pagination when fetching more than one page
func TestGmailList_Pagination_MockServer(t *testing.T) {
pageRequests := 0

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

// Threads list with pagination
if r.URL.Path == "/gmail/v1/users/me/threads" && r.Method == "GET" {
pageRequests++
pageToken := r.URL.Query().Get("pageToken")

var resp map[string]interface{}
if pageToken == "" {
// First page
resp = map[string]interface{}{
"threads": []map[string]interface{}{
{"id": "thread-1", "snippet": "First"},
{"id": "thread-2", "snippet": "Second"},
},
"nextPageToken": "page2token",
"resultSizeEstimate": 4,
}
} else if pageToken == "page2token" {
// Second page
resp = map[string]interface{}{
"threads": []map[string]interface{}{
{"id": "thread-3", "snippet": "Third"},
{"id": "thread-4", "snippet": "Fourth"},
},
"resultSizeEstimate": 4,
}
} else {
t.Errorf("unexpected page token: %s", pageToken)
w.WriteHeader(http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(resp)
return
}

// Thread get (for metadata)
if strings.HasPrefix(r.URL.Path, "/gmail/v1/users/me/threads/thread-") && r.Method == "GET" {
threadID := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/threads/")
resp := map[string]interface{}{
"id": threadID,
"messages": []map[string]interface{}{
{
"id": "msg-" + threadID,
"threadId": threadID,
"payload": map[string]interface{}{
"headers": []map[string]string{
{"name": "Subject", "value": "Test " + threadID},
{"name": "From", "value": "test@example.com"},
{"name": "Date", "value": "Mon, 1 Jan 2024 10:00:00 +0000"},
},
},
},
},
}
json.NewEncoder(w).Encode(resp)
return
}

t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL))
if err != nil {
t.Fatalf("failed to create gmail service: %v", err)
}

// Simulate pagination: fetch all threads
var allThreads []*gmail.Thread
var pageToken string
for {
call := svc.Users.Threads.List("me").MaxResults(500)
if pageToken != "" {
call = call.PageToken(pageToken)
}

resp, err := call.Do()
if err != nil {
t.Fatalf("failed to list threads: %v", err)
}

allThreads = append(allThreads, resp.Threads...)

if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}

// Verify we got all 4 threads across 2 pages
if len(allThreads) != 4 {
t.Errorf("expected 4 threads, got %d", len(allThreads))
}
if pageRequests != 2 {
t.Errorf("expected 2 page requests, got %d", pageRequests)
}
}

// TestGmailList_MaxRespected_MockServer tests that --max limits results even with pagination
func TestGmailList_MaxRespected_MockServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

if r.URL.Path == "/gmail/v1/users/me/threads" && r.Method == "GET" {
maxResults := r.URL.Query().Get("maxResults")
// The request should respect the max parameter
if maxResults != "3" {
t.Logf("maxResults requested: %s", maxResults)
}

resp := map[string]interface{}{
"threads": []map[string]interface{}{
{"id": "thread-1", "snippet": "First"},
{"id": "thread-2", "snippet": "Second"},
{"id": "thread-3", "snippet": "Third"},
},
"nextPageToken": "moretoken",
"resultSizeEstimate": 100,
}
json.NewEncoder(w).Encode(resp)
return
}

if strings.HasPrefix(r.URL.Path, "/gmail/v1/users/me/threads/thread-") && r.Method == "GET" {
threadID := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/threads/")
resp := map[string]interface{}{
"id": threadID,
"messages": []map[string]interface{}{
{"id": "msg-1", "payload": map[string]interface{}{"headers": []map[string]string{}}},
},
}
json.NewEncoder(w).Encode(resp)
return
}

w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL))
if err != nil {
t.Fatalf("failed to create gmail service: %v", err)
}

// Request only 3 results
resp, err := svc.Users.Threads.List("me").MaxResults(3).Do()
if err != nil {
t.Fatalf("failed to list threads: %v", err)
}

if len(resp.Threads) != 3 {
t.Errorf("expected 3 threads, got %d", len(resp.Threads))
}
}