diff --git a/CHANGELOG.md b/CHANGELOG.md index aac8470..7bfb9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Calendar: respond patches only attendees to avoid custom reminders validation errors. (#265) — thanks @sebasrodriguez. - Secrets: respect empty `GOG_KEYRING_PASSWORD` (treat set-to-empty as intentional; avoids headless prompts). (#269) — thanks @zerone0x. +- Calendar: reject ambiguous calendar-name selectors for `calendar events` instead of guessing. (#131) — thanks @salmonumbrella. ## 0.11.0 - 2026-02-15 diff --git a/README.md b/README.md index c7df98a..43052fc 100644 --- a/README.md +++ b/README.md @@ -678,6 +678,8 @@ gog calendar events --from today --to friday # Relative dates gog calendar events --from today --to friday --weekday # Include weekday columns gog calendar events --from 2025-01-01T00:00:00Z --to 2025-01-08T00:00:00Z gog calendar events --all # Fetch events from all calendars +gog calendar events --calendars 1,3 # Fetch events from calendar indices (see gog calendar calendars) +gog calendar events --cal Work --cal Personal # Fetch events from calendars by name/ID gog calendar event gog calendar get # Alias for event gog calendar search "meeting" --today diff --git a/docs/spec.md b/docs/spec.md index 74963e2..99c309e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -194,7 +194,7 @@ Flag aliases: - `gog drive drives [--max N] [--page TOKEN] [--query Q]` - `gog calendar calendars` - `gog calendar acl ` -- `gog calendar events [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]` +- `gog calendar events [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]` - `gog calendar event|get ` - `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events` - `gog calendar create --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]` diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index be724f0..ac41aac 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -196,24 +196,26 @@ func (c *CalendarAclCmd) Run(ctx context.Context, flags *RootFlags) error { } type CalendarEventsCmd struct { - CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary)"` - From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"` - To string `name:"to" help:"End time (RFC3339, date, or relative)"` - Today bool `name:"today" help:"Today only (timezone-aware)"` - Tomorrow bool `name:"tomorrow" help:"Tomorrow only (timezone-aware)"` - Week bool `name:"week" help:"This week (uses --week-start, default Mon)"` - Days int `name:"days" help:"Next N days (timezone-aware)" default:"0"` - WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""` - Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` - Page string `name:"page" aliases:"cursor" help:"Page token"` - AllPages bool `name:"all-pages" aliases:"allpages" help:"Fetch all pages"` - FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` - Query string `name:"query" help:"Free text search"` - All bool `name:"all" help:"Fetch events from all calendars"` - PrivatePropFilter string `name:"private-prop-filter" help:"Filter by private extended property (key=value)"` - SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"` - Fields string `name:"fields" help:"Comma-separated fields to return"` - Weekday bool `name:"weekday" help:"Include start/end day-of-week columns" default:"${calendar_weekday}"` + CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary)"` + Cal []string `name:"cal" help:"Calendar ID or name (can be repeated)"` + Calendars string `name:"calendars" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"` + From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"` + To string `name:"to" help:"End time (RFC3339, date, or relative)"` + Today bool `name:"today" help:"Today only (timezone-aware)"` + Tomorrow bool `name:"tomorrow" help:"Tomorrow only (timezone-aware)"` + Week bool `name:"week" help:"This week (uses --week-start, default Mon)"` + Days int `name:"days" help:"Next N days (timezone-aware)" default:"0"` + WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` + Page string `name:"page" aliases:"cursor" help:"Page token"` + AllPages bool `name:"all-pages" aliases:"allpages" help:"Fetch all pages"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` + Query string `name:"query" help:"Free text search"` + All bool `name:"all" help:"Fetch events from all calendars"` + PrivatePropFilter string `name:"private-prop-filter" help:"Filter by private extended property (key=value)"` + SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"` + Fields string `name:"fields" help:"Comma-separated fields to return"` + Weekday bool `name:"weekday" help:"Include start/end day-of-week columns" default:"${calendar_weekday}"` } func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -223,10 +225,17 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { } calendarID := strings.TrimSpace(c.CalendarID) - if c.All && calendarID != "" { - return usage("calendarId not allowed with --all flag") + calInputs := append([]string{}, c.Cal...) + if strings.TrimSpace(c.Calendars) != "" { + calInputs = append(calInputs, splitCSV(c.Calendars)...) } - if !c.All && calendarID == "" { + if c.All && (calendarID != "" || len(calInputs) > 0) { + return usage("calendarId or --cal/--calendars not allowed with --all flag") + } + if calendarID != "" && len(calInputs) > 0 { + return usage("calendarId not allowed with --cal/--calendars") + } + if !c.All && calendarID == "" && len(calInputs) == 0 { calendarID = primaryCalendarID } @@ -260,6 +269,16 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { if c.All { return listAllCalendarsEvents(ctx, svc, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) } + if len(calInputs) > 0 { + ids, err := resolveCalendarIDs(ctx, svc, calInputs) + if err != nil { + return err + } + if len(ids) == 0 { + return usage("no calendars specified") + } + return listSelectedCalendarsEvents(ctx, svc, ids, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) + } return listCalendarEvents(ctx, svc, calendarID, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) } diff --git a/internal/cmd/calendar_events_test.go b/internal/cmd/calendar_events_test.go index e2a76a1..ca31318 100644 --- a/internal/cmd/calendar_events_test.go +++ b/internal/cmd/calendar_events_test.go @@ -3,10 +3,12 @@ package cmd import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" "strings" + "sync" "testing" "google.golang.org/api/calendar/v3" @@ -115,3 +117,246 @@ func TestCalendarEventsCmd_DefaultsToPrimary(t *testing.T) { t.Fatalf("unexpected output: %q", out) } } + +func TestCalendarEventsCmd_CalendarsFlag(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + var mu sync.Mutex + calls := make(map[string]int) + + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/calendarList") && + !strings.Contains(r.URL.Path, "/calendarList/primary") && + r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "c1", "summary": "Work"}, + {"id": "c2", "summary": "Family"}, + {"id": "c3", "summary": "Other"}, + }, + }) + return + case strings.Contains(r.URL.Path, "/calendars/c1/events") && r.Method == http.MethodGet: + mu.Lock() + calls["c1"]++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "e1", "summary": "Event 1"}, + }, + }) + return + case strings.Contains(r.URL.Path, "/calendars/c2/events") && r.Method == http.MethodGet: + mu.Lock() + calls["c2"]++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "e2", "summary": "Event 2"}, + }, + }) + return + case strings.Contains(r.URL.Path, "/calendars/c3/events") && r.Method == http.MethodGet: + mu.Lock() + calls["c3"]++ + mu.Unlock() + http.Error(w, "unexpected calendar", http.StatusBadRequest) + return + default: + http.NotFound(w, r) + return + } + }))) + defer srv.Close() + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + flags := &RootFlags{Account: "a@b.com"} + + cmd := &CalendarEventsCmd{ + Calendars: "1,Family", + From: "2025-01-01T00:00:00Z", + To: "2025-01-02T00:00:00Z", + } + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed struct { + Events []map[string]any `json:"events"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if len(parsed.Events) != 2 { + t.Fatalf("unexpected events: %#v", parsed.Events) + } + + mu.Lock() + defer mu.Unlock() + if calls["c1"] == 0 || calls["c2"] == 0 || calls["c3"] != 0 { + t.Fatalf("unexpected calendar calls: %#v", calls) + } +} + +func TestResolveCalendarIDs_IndexOutOfRange(t *testing.T) { + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendarList") && + !strings.Contains(r.URL.Path, "/calendarList/primary") && + r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "c1", "summary": "Work"}, + }, + }) + return + } + http.NotFound(w, r) + }))) + defer srv.Close() + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + _, err = resolveCalendarIDs(context.Background(), svc, []string{"2"}) + if err == nil { + t.Fatalf("expected error") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected usage error, got %v", err) + } +} + +func TestResolveCalendarIDs_AmbiguousName(t *testing.T) { + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendarList") && + !strings.Contains(r.URL.Path, "/calendarList/primary") && + r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "c1", "summary": "Work"}, + {"id": "c2", "summary": "Work"}, + {"id": "c3", "summary": "Family"}, + }, + }) + return + } + http.NotFound(w, r) + }))) + defer srv.Close() + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + _, err = resolveCalendarIDs(context.Background(), svc, []string{"Work"}) + if err == nil { + t.Fatalf("expected error") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected usage error, got %v", err) + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected ambiguous error, got %v", err) + } +} + +func TestResolveCalendarIDs_UnrecognizedName(t *testing.T) { + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendarList") && + !strings.Contains(r.URL.Path, "/calendarList/primary") && + r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "c1", "summary": "Work"}, + {"id": "c2", "summary": "Family"}, + }, + }) + return + } + http.NotFound(w, r) + }))) + defer srv.Close() + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + // Test single unrecognized name + _, err = resolveCalendarIDs(context.Background(), svc, []string{"NonExistent"}) + if err == nil { + t.Fatalf("expected error for unrecognized calendar name") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected usage error, got %v", err) + } + if !strings.Contains(err.Error(), "unrecognized calendar name(s)") { + t.Fatalf("expected error message to mention unrecognized calendar, got: %v", err) + } + if !strings.Contains(err.Error(), "NonExistent") { + t.Fatalf("expected error message to include the unrecognized name, got: %v", err) + } + + // Test multiple unrecognized names + _, err = resolveCalendarIDs(context.Background(), svc, []string{"Work", "Unknown1", "Unknown2"}) + if err == nil { + t.Fatalf("expected error for unrecognized calendar names") + } + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected usage error, got %v", err) + } + if !strings.Contains(err.Error(), "Unknown1") || !strings.Contains(err.Error(), "Unknown2") { + t.Fatalf("expected error message to include all unrecognized names, got: %v", err) + } + + // Test valid names still work + ids, err := resolveCalendarIDs(context.Background(), svc, []string{"Work", "Family"}) + if err != nil { + t.Fatalf("unexpected error for valid calendar names: %v", err) + } + if len(ids) != 2 || ids[0] != "c1" || ids[1] != "c2" { + t.Fatalf("unexpected ids: %v", ids) + } +} diff --git a/internal/cmd/calendar_list.go b/internal/cmd/calendar_list.go index 8fb2a1b..b22e073 100644 --- a/internal/cmd/calendar_list.go +++ b/internal/cmd/calendar_list.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "google.golang.org/api/calendar/v3" @@ -112,20 +113,45 @@ type eventWithCalendar struct { func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool) error { u := ui.FromContext(ctx) - calResp, err := svc.CalendarList.List().Context(ctx).Do() + calendars, err := listCalendarList(ctx, svc) if err != nil { return err } - if len(calResp.Items) == 0 { + if len(calendars) == 0 { u.Err().Println("No calendars") return failEmptyExit(failEmpty) } + ids := make([]string, 0, len(calendars)) + for _, cal := range calendars { + if cal == nil || strings.TrimSpace(cal.Id) == "" { + continue + } + ids = append(ids, cal.Id) + } + if len(ids) == 0 { + u.Err().Println("No calendars") + return nil + } + return listCalendarIDsEvents(ctx, svc, ids, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday) +} + +func listSelectedCalendarsEvents(ctx context.Context, svc *calendar.Service, calendarIDs []string, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool) error { + return listCalendarIDsEvents(ctx, svc, calendarIDs, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday) +} + +func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarIDs []string, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool) error { + u := ui.FromContext(ctx) + all := []*eventWithCalendar{} - for _, cal := range calResp.Items { + for _, calID := range calendarIDs { + calID = strings.TrimSpace(calID) + if calID == "" { + continue + } fetch := func(pageToken string) ([]*calendar.Event, string, error) { - call := svc.Events.List(cal.Id). + call := svc.Events.List(calID). TimeMin(from). TimeMax(to). MaxResults(maxResults). @@ -146,25 +172,26 @@ func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to if strings.TrimSpace(fields) != "" { call = call.Fields(gapi.Field(fields)) } - events, callErr := call.Context(ctx).Do() - if callErr != nil { - return nil, "", callErr + resp, err := call.Context(ctx).Do() + if err != nil { + return nil, "", err } - return events.Items, events.NextPageToken, nil + return resp.Items, resp.NextPageToken, nil } var events []*calendar.Event + var err error if allPages { allEvents, collectErr := collectAllPages(page, fetch) if collectErr != nil { - u.Err().Printf("calendar %s: %v", cal.Id, collectErr) + u.Err().Printf("calendar %s: %v", calID, collectErr) continue } events = allEvents } else { events, _, err = fetch(page) if err != nil { - u.Err().Printf("calendar %s: %v", cal.Id, err) + u.Err().Printf("calendar %s: %v", calID, err) continue } } @@ -176,7 +203,7 @@ func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to endLocal := formatEventLocal(e.End, nil) all = append(all, &eventWithCalendar{ Event: e, - CalendarID: cal.Id, + CalendarID: calID, StartDayOfWeek: startDay, EndDayOfWeek: endDay, Timezone: evTimezone, @@ -216,3 +243,126 @@ func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to } return nil } + +func resolveCalendarIDs(ctx context.Context, svc *calendar.Service, inputs []string) ([]string, error) { + if len(inputs) == 0 { + return nil, nil + } + calendars, err := listCalendarList(ctx, svc) + if err != nil { + return nil, err + } + + bySummary := make(map[string][]string, len(calendars)) + byID := make(map[string]string, len(calendars)) + for _, cal := range calendars { + if cal == nil { + continue + } + if strings.TrimSpace(cal.Id) != "" { + byID[strings.ToLower(strings.TrimSpace(cal.Id))] = cal.Id + } + if strings.TrimSpace(cal.Summary) != "" { + summaryKey := strings.ToLower(strings.TrimSpace(cal.Summary)) + bySummary[summaryKey] = append(bySummary[summaryKey], cal.Id) + } + } + + out := make([]string, 0, len(inputs)) + seen := make(map[string]struct{}, len(inputs)) + var unrecognized []string + + for _, raw := range inputs { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if isDigits(value) { + idx, err := strconv.Atoi(value) + if err != nil { + return nil, usagef("invalid calendar index: %s", value) + } + if idx < 1 || idx > len(calendars) { + return nil, usagef("calendar index %d out of range (have %d calendars)", idx, len(calendars)) + } + cal := calendars[idx-1] + if cal == nil || strings.TrimSpace(cal.Id) == "" { + return nil, usagef("calendar index %d has no id", idx) + } + appendUniqueCalendarID(&out, seen, cal.Id) + continue + } + + key := strings.ToLower(value) + if ids, ok := bySummary[key]; ok { + if len(ids) > 1 { + return nil, usagef("calendar name %q is ambiguous", value) + } + if len(ids) == 1 { + appendUniqueCalendarID(&out, seen, ids[0]) + continue + } + continue + } + if id, ok := byID[key]; ok { + appendUniqueCalendarID(&out, seen, id) + continue + } + unrecognized = append(unrecognized, value) + } + + if len(unrecognized) > 0 { + return nil, usagef("unrecognized calendar name(s): %s", strings.Join(unrecognized, ", ")) + } + + return out, nil +} + +func listCalendarList(ctx context.Context, svc *calendar.Service) ([]*calendar.CalendarListEntry, error) { + var ( + items []*calendar.CalendarListEntry + pageToken string + ) + for { + call := svc.CalendarList.List().MaxResults(250).Context(ctx) + if pageToken != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Do() + if err != nil { + return nil, err + } + if len(resp.Items) > 0 { + items = append(items, resp.Items...) + } + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + return items, nil +} + +func appendUniqueCalendarID(out *[]string, seen map[string]struct{}, id string) { + id = strings.TrimSpace(id) + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + *out = append(*out, id) +} + +func isDigits(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index b4b8c77..082bc75 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -171,6 +171,7 @@ func (c *GmailDraftsGetCmd) Run(ctx context.Context, flags *RootFlags) error { u.Out().Printf("Message-ID: %s", msg.Id) u.Out().Printf("To: %s", headerValue(msg.Payload, "To")) u.Out().Printf("Cc: %s", headerValue(msg.Payload, "Cc")) + u.Out().Printf("Bcc: %s", headerValue(msg.Payload, "Bcc")) u.Out().Printf("Subject: %s", headerValue(msg.Payload, "Subject")) u.Out().Println("") diff --git a/internal/cmd/gmail_get.go b/internal/cmd/gmail_get.go index 8cf6194..6b12c5d 100644 --- a/internal/cmd/gmail_get.go +++ b/internal/cmd/gmail_get.go @@ -54,7 +54,7 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error { if format == gmailFormatMetadata { headerList := splitCSV(c.Headers) if len(headerList) == 0 { - headerList = []string{"From", "To", "Subject", "Date"} + headerList = []string{"From", "To", "Cc", "Bcc", "Subject", "Date"} } if !hasHeaderName(headerList, "List-Unsubscribe") { headerList = append(headerList, "List-Unsubscribe") @@ -120,6 +120,8 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error { case gmailFormatMetadata, gmailFormatFull: u.Out().Printf("from\t%s", headerValue(msg.Payload, "From")) u.Out().Printf("to\t%s", headerValue(msg.Payload, "To")) + u.Out().Printf("cc\t%s", headerValue(msg.Payload, "Cc")) + u.Out().Printf("bcc\t%s", headerValue(msg.Payload, "Bcc")) u.Out().Printf("subject\t%s", headerValue(msg.Payload, "Subject")) u.Out().Printf("date\t%s", headerValue(msg.Payload, "Date")) if unsubscribe != "" { diff --git a/internal/cmd/gmail_get_cmd_test.go b/internal/cmd/gmail_get_cmd_test.go index f30de23..99f126a 100644 --- a/internal/cmd/gmail_get_cmd_test.go +++ b/internal/cmd/gmail_get_cmd_test.go @@ -39,6 +39,8 @@ func TestGmailGetCmd_JSON_Full(t *testing.T) { "headers": []map[string]any{ {"name": "From", "value": "a@example.com"}, {"name": "To", "value": "b@example.com"}, + {"name": "Cc", "value": "c@example.com"}, + {"name": "Bcc", "value": "d@example.com"}, {"name": "Subject", "value": "S"}, {"name": "Date", "value": "Fri, 26 Dec 2025 10:00:00 +0000"}, {"name": "List-Unsubscribe", "value": ""}, @@ -85,6 +87,16 @@ func TestGmailGetCmd_JSON_Full(t *testing.T) { if parsed["unsubscribe"] != "mailto:unsubscribe@example.com" { t.Fatalf("unexpected unsubscribe: %v", parsed["unsubscribe"]) } + headers, ok := parsed["headers"].(map[string]any) + if !ok { + t.Fatalf("expected headers map, got: %T", parsed["headers"]) + } + if headers["cc"] != "c@example.com" { + t.Fatalf("unexpected cc header: %v", headers["cc"]) + } + if headers["bcc"] != "d@example.com" { + t.Fatalf("unexpected bcc header: %v", headers["bcc"]) + } } func TestGmailGetCmd_JSON_Full_WithAttachments(t *testing.T) { @@ -204,6 +216,8 @@ func TestGmailGetCmd_JSON_Metadata_WithAttachments(t *testing.T) { "headers": []map[string]any{ {"name": "From", "value": "a@example.com"}, {"name": "To", "value": "b@example.com"}, + {"name": "Cc", "value": "c@example.com"}, + {"name": "Bcc", "value": "d@example.com"}, {"name": "Subject", "value": "Metadata attachments"}, {"name": "Date", "value": "Fri, 26 Dec 2025 10:00:00 +0000"}, }, @@ -256,6 +270,16 @@ func TestGmailGetCmd_JSON_Metadata_WithAttachments(t *testing.T) { if _, ok := parsed["body"]; ok { t.Fatalf("expected no body for metadata output") } + headers, ok := parsed["headers"].(map[string]any) + if !ok { + t.Fatalf("expected headers map, got: %T", parsed["headers"]) + } + if headers["cc"] != "c@example.com" { + t.Fatalf("unexpected cc header: %v", headers["cc"]) + } + if headers["bcc"] != "d@example.com" { + t.Fatalf("unexpected bcc header: %v", headers["bcc"]) + } attachments, ok := parsed["attachments"].([]any) if !ok || len(attachments) != 1 { t.Fatalf("expected 1 attachment, got: %v", parsed["attachments"]) @@ -298,6 +322,8 @@ func TestGmailGetCmd_Text_Full_WithAttachments(t *testing.T) { "headers": []map[string]any{ {"name": "From", "value": "a@example.com"}, {"name": "To", "value": "b@example.com"}, + {"name": "Cc", "value": "c@example.com"}, + {"name": "Bcc", "value": "d@example.com"}, {"name": "Subject", "value": "Test"}, {"name": "Date", "value": "Fri, 26 Dec 2025 10:00:00 +0000"}, }, @@ -349,6 +375,12 @@ func TestGmailGetCmd_Text_Full_WithAttachments(t *testing.T) { if !strings.Contains(out, "attachment\treport.pdf\t"+formatBytes(54321)+"\tapplication/pdf\tANGjdJ-xyz789") { t.Fatalf("expected attachment line in output, got: %q", out) } + if !strings.Contains(out, "cc\tc@example.com") { + t.Fatalf("expected cc header in output, got: %q", out) + } + if !strings.Contains(out, "bcc\td@example.com") { + t.Fatalf("expected bcc header in output, got: %q", out) + } } func TestGmailGetCmd_Text_Metadata_WithAttachments(t *testing.T) { @@ -371,6 +403,8 @@ func TestGmailGetCmd_Text_Metadata_WithAttachments(t *testing.T) { "headers": []map[string]any{ {"name": "From", "value": "a@example.com"}, {"name": "To", "value": "b@example.com"}, + {"name": "Cc", "value": "c@example.com"}, + {"name": "Bcc", "value": "d@example.com"}, {"name": "Subject", "value": "Metadata"}, {"name": "Date", "value": "Fri, 26 Dec 2025 10:00:00 +0000"}, }, @@ -425,6 +459,12 @@ func TestGmailGetCmd_Text_Metadata_WithAttachments(t *testing.T) { if strings.Contains(out, "metadata body") { t.Fatalf("unexpected body output for metadata: %q", out) } + if !strings.Contains(out, "cc\tc@example.com") { + t.Fatalf("expected cc header in output, got: %q", out) + } + if !strings.Contains(out, "bcc\td@example.com") { + t.Fatalf("expected bcc header in output, got: %q", out) + } } func TestGmailGetCmd_RawEmpty(t *testing.T) { diff --git a/internal/cmd/gmail_thread.go b/internal/cmd/gmail_thread.go index 6bf3ee0..37f18d5 100644 --- a/internal/cmd/gmail_thread.go +++ b/internal/cmd/gmail_thread.go @@ -16,6 +16,7 @@ import ( "strings" "golang.org/x/net/html/charset" + "golang.org/x/text/encoding/ianaindex" "google.golang.org/api/gmail/v1" "github.com/steipete/gogcli/internal/config" @@ -489,23 +490,69 @@ func decodeTransferEncoding(data []byte, encoding string) []byte { } func decodeBodyCharset(data []byte, contentType string) []byte { - _, params, err := mime.ParseMediaType(contentType) - if err != nil { + charsetLabel := charsetLabelFromContentType(contentType) + normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(charsetLabel), "_", "-")) + if charsetLabel == "" || normalized == "utf-8" || normalized == "utf8" { return data } - charsetLabel := strings.TrimSpace(params["charset"]) - if charsetLabel == "" || strings.EqualFold(charsetLabel, "utf-8") { - return data + if decoded, ok := decodeWithCharsetLabel(data, charsetLabel); ok { + return decoded + } + return data +} + +func charsetLabelFromContentType(contentType string) string { + _, params, err := mime.ParseMediaType(contentType) + if err == nil { + if label := strings.TrimSpace(params["charset"]); label != "" { + return label + } + } + lower := strings.ToLower(contentType) + idx := strings.Index(lower, "charset=") + if idx == -1 { + return "" + } + label := contentType[idx+len("charset="):] + label = strings.TrimLeft(label, " \t") + if cut := strings.IndexAny(label, "; \t"); cut != -1 { + label = label[:cut] + } + return strings.Trim(label, "\"'") +} + +func decodeWithCharsetLabel(data []byte, charsetLabel string) ([]byte, bool) { + label := strings.TrimSpace(charsetLabel) + if label == "" { + return nil, false + } + if decoded, ok := decodeWithEncodingIndex(data, label); ok { + return decoded, true + } + if strings.Contains(label, "_") { + alt := strings.ReplaceAll(label, "_", "-") + if decoded, ok := decodeWithEncodingIndex(data, alt); ok { + return decoded, true + } + } + return nil, false +} + +func decodeWithEncodingIndex(data []byte, charsetLabel string) ([]byte, bool) { + if enc, err := ianaindex.MIME.Encoding(charsetLabel); err == nil && enc != nil { + if decoded, err := enc.NewDecoder().Bytes(data); err == nil { + return decoded, true + } } reader, err := charset.NewReaderLabel(charsetLabel, bytes.NewReader(data)) if err != nil { - return data + return nil, false } decoded, err := io.ReadAll(reader) if err != nil { - return data + return nil, false } - return decoded + return decoded, true } func looksLikeBase64(data []byte) bool { diff --git a/internal/cmd/gmail_thread_helpers_test.go b/internal/cmd/gmail_thread_helpers_test.go index 463dce3..7bb42f8 100644 --- a/internal/cmd/gmail_thread_helpers_test.go +++ b/internal/cmd/gmail_thread_helpers_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "golang.org/x/text/encoding/japanese" "google.golang.org/api/gmail/v1" ) @@ -239,6 +240,63 @@ func TestDecodeBodyCharset_ISO88591(t *testing.T) { } } +func TestDecodeBodyCharset_ISO2022JP(t *testing.T) { + source := "\u65e5\u672c\u8a9e\u30c6\u30b9\u30c8" + encoded, err := japanese.ISO2022JP.NewEncoder().Bytes([]byte(source)) + if err != nil { + t.Fatalf("encode iso-2022-jp: %v", err) + } + got := decodeBodyCharset(encoded, "text/plain; charset=iso-2022-jp") + if string(got) != source { + t.Fatalf("unexpected decoded charset: %q", string(got)) + } +} + +func TestDecodeBodyCharset_ISO2022JP_MixedASCIIAndJapanese(t *testing.T) { + // Test mixed ASCII and Japanese text (e.g., "Hello こんにちは World") + source := "Hello \u3053\u3093\u306b\u3061\u306f World" + encoded, err := japanese.ISO2022JP.NewEncoder().Bytes([]byte(source)) + if err != nil { + t.Fatalf("encode iso-2022-jp: %v", err) + } + got := decodeBodyCharset(encoded, "text/plain; charset=iso-2022-jp") + if string(got) != source { + t.Fatalf("unexpected decoded charset: expected %q, got %q", source, string(got)) + } +} + +func TestDecodeBodyCharset_ISO2022JP_EmptyContent(t *testing.T) { + // Test empty content with ISO-2022-JP charset header + got := decodeBodyCharset([]byte{}, "text/plain; charset=iso-2022-jp") + if len(got) != 0 { + t.Fatalf("expected empty result for empty input, got %q", string(got)) + } +} + +func TestDecodeBodyCharset_ISO2022JP_MalformedSequence(t *testing.T) { + // Test malformed ISO-2022-JP sequences - should gracefully return original data + // ISO-2022-JP uses escape sequences like ESC $ B for switching to JIS X 0208 + // This creates an invalid sequence: starts escape but doesn't complete properly + malformed := []byte{0x1b, 0x24, 0x42, 0xff, 0xfe, 0x1b, 0x28, 0x42} // ESC $ B + invalid bytes + ESC ( B + got := decodeBodyCharset(malformed, "text/plain; charset=iso-2022-jp") + // The decoder should either return the original malformed data or a decoded version + // (graceful degradation means it shouldn't panic or error) + if got == nil { + t.Fatalf("expected non-nil result for malformed input") + } +} + +func TestDecodeBodyCharset_ISO2022JP_TruncatedEscapeSequence(t *testing.T) { + // Test truncated escape sequence - incomplete ISO-2022-JP escape + // ESC $ without the final byte is incomplete + truncated := []byte{0x1b, 0x24} + got := decodeBodyCharset(truncated, "text/plain; charset=iso-2022-jp") + // Should gracefully handle and return something (original or partial decode) + if got == nil { + t.Fatalf("expected non-nil result for truncated escape sequence") + } +} + func TestMimeTypeMatches(t *testing.T) { if !mimeTypeMatches("Text/Plain; charset=UTF-8", "text/plain") { t.Fatalf("expected mime match")