gogcli/internal/cmd/gmail_get.go
salmonumbrella 9977c0bedb
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>
2026-02-16 06:56:42 +01:00

147 lines
3.9 KiB
Go

package cmd
import (
"context"
"encoding/base64"
"fmt"
"os"
"strings"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type GmailGetCmd struct {
MessageID string `arg:"" name:"messageId" help:"Message ID"`
Format string `name:"format" help:"Message format: full|metadata|raw" default:"full"`
Headers string `name:"headers" help:"Metadata headers (comma-separated; only for --format=metadata)"`
}
const (
gmailFormatFull = "full"
gmailFormatMetadata = "metadata"
gmailFormatRaw = "raw"
)
func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
messageID := strings.TrimSpace(c.MessageID)
messageID = normalizeGmailMessageID(messageID)
if messageID == "" {
return usage("empty messageId")
}
format := strings.TrimSpace(c.Format)
if format == "" {
format = gmailFormatFull
}
switch format {
case gmailFormatFull, gmailFormatMetadata, gmailFormatRaw:
default:
return fmt.Errorf("invalid --format: %q (expected full|metadata|raw)", format)
}
svc, err := newGmailService(ctx, account)
if err != nil {
return err
}
call := svc.Users.Messages.Get("me", messageID).Format(format).Context(ctx)
if format == gmailFormatMetadata {
headerList := splitCSV(c.Headers)
if len(headerList) == 0 {
headerList = []string{"From", "To", "Cc", "Bcc", "Subject", "Date"}
}
if !hasHeaderName(headerList, "List-Unsubscribe") {
headerList = append(headerList, "List-Unsubscribe")
}
call = call.MetadataHeaders(headerList...)
}
msg, err := call.Do()
if err != nil {
return err
}
unsubscribe := bestUnsubscribeLink(msg.Payload)
if outfmt.IsJSON(ctx) {
// Include a flattened headers map for easier querying
// (e.g., jq '.headers.to' instead of complex nested queries)
headers := map[string]string{
"from": headerValue(msg.Payload, "From"),
"to": headerValue(msg.Payload, "To"),
"cc": headerValue(msg.Payload, "Cc"),
"bcc": headerValue(msg.Payload, "Bcc"),
"subject": headerValue(msg.Payload, "Subject"),
"date": headerValue(msg.Payload, "Date"),
}
payload := map[string]any{
"message": msg,
"headers": headers,
}
if unsubscribe != "" {
payload["unsubscribe"] = unsubscribe
}
if format == gmailFormatFull {
if body := bestBodyText(msg.Payload); body != "" {
payload["body"] = body
}
}
if format == gmailFormatFull || format == gmailFormatMetadata {
attachments := collectAttachments(msg.Payload)
if len(attachments) > 0 {
payload["attachments"] = attachmentOutputs(attachments)
}
}
return outfmt.WriteJSON(ctx, os.Stdout, payload)
}
u.Out().Printf("id\t%s", msg.Id)
u.Out().Printf("thread_id\t%s", msg.ThreadId)
u.Out().Printf("label_ids\t%s", strings.Join(msg.LabelIds, ","))
switch format {
case gmailFormatRaw:
if msg.Raw == "" {
u.Err().Println("Empty raw message")
return nil
}
decoded, err := base64.RawURLEncoding.DecodeString(msg.Raw)
if err != nil {
return err
}
u.Out().Println("")
u.Out().Println(string(decoded))
return nil
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 != "" {
u.Out().Printf("unsubscribe\t%s", unsubscribe)
}
attachments := attachmentOutputs(collectAttachments(msg.Payload))
if len(attachments) > 0 {
u.Out().Println("")
printAttachmentLines(u.Out(), attachments)
}
if format == gmailFormatFull {
body := bestBodyText(msg.Payload)
if body != "" {
u.Out().Println("")
u.Out().Println(body)
}
}
return nil
default:
return nil
}
}