From 9977c0bedbc528b2e46b0b002a15deeb76cefd3b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:56:42 -0800 Subject: [PATCH] fix(gmail/calendar): ISO-2022-JP decoding, cc/bcc headers, calendar selection (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gmail): decode ISO-2022-JP bodies * fix(gmail): include cc/bcc in get output * feat(calendar): allow selecting calendars in events * test(gmail): add edge case tests for ISO-2022-JP decoding Add tests for edge cases in ISO-2022-JP body decoding: - Mixed ASCII and Japanese text (e.g., "Hello こんにちは World") - Empty content with ISO-2022-JP charset header - Malformed ISO-2022-JP sequences (graceful degradation) - Truncated escape sequences These tests verify the graceful fallback behavior in decodeBodyCharset which returns original data if decoding fails. Co-Authored-By: Claude Opus 4.5 * fix(calendar): validate unknown calendar names in resolveCalendarIDs When a calendar name doesn't match any known calendar (not in bySummary or byID maps), return an error listing the unrecognized names instead of treating them as raw calendar IDs which causes cryptic Google API errors. Co-Authored-By: Claude Opus 4.5 * fix(calendar): validate unknown and ambiguous calendar name resolutions --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + README.md | 2 + docs/spec.md | 2 +- internal/cmd/calendar.go | 61 ++++-- internal/cmd/calendar_events_test.go | 245 ++++++++++++++++++++++ internal/cmd/calendar_list.go | 172 ++++++++++++++- internal/cmd/gmail_drafts.go | 1 + internal/cmd/gmail_get.go | 4 +- internal/cmd/gmail_get_cmd_test.go | 40 ++++ internal/cmd/gmail_thread.go | 63 +++++- internal/cmd/gmail_thread_helpers_test.go | 58 +++++ 11 files changed, 607 insertions(+), 42 deletions(-) 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")