fix(gmail/calendar): ISO-2022-JP decoding, cc/bcc headers, calendar selection (#131)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(calendar): validate unknown and ambiguous calendar name resolutions

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
salmonumbrella 2026-02-15 21:56:42 -08:00 committed by GitHub
parent 04f6ff216c
commit 9977c0bedb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 607 additions and 42 deletions

View File

@ -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

View File

@ -678,6 +678,8 @@ gog calendar events <calendarId> --from today --to friday # Relative dates
gog calendar events <calendarId> --from today --to friday --weekday # Include weekday columns
gog calendar events <calendarId> --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 <calendarId> <eventId>
gog calendar get <calendarId> <eventId> # Alias for event
gog calendar search "meeting" --today

View File

@ -194,7 +194,7 @@ Flag aliases:
- `gog drive drives [--max N] [--page TOKEN] [--query Q]`
- `gog calendar calendars`
- `gog calendar acl <calendarId>`
- `gog calendar events <calendarId> [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
- `gog calendar event|get <calendarId> <eventId>`
- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events`
- `gog calendar create <calendarId> --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]`

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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("")

View File

@ -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 != "" {

View File

@ -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": "<mailto:unsubscribe@example.com>"},
@ -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) {

View File

@ -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 {

View File

@ -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")