diff --git a/docs/commands/gog-gmail-messages-search.md b/docs/commands/gog-gmail-messages-search.md index 7364cf42b..1114e6d71 100644 --- a/docs/commands/gog-gmail-messages-search.md +++ b/docs/commands/gog-gmail-messages-search.md @@ -34,6 +34,7 @@ gog gmail (mail,email) messages (message,msg,msgs) search (find,query,ls,list) < | `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | | `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `--include-attachments` | `bool` | | Include each message's attachment metadata | | `--include-body` | `bool` | | Include decoded message body (JSON is full; text output truncates only unusually large bodies) | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--local` | `bool` | | Use local timezone (default behavior, useful to override --timezone) | diff --git a/internal/cmd/execute_gmail_messages_include_attachments_test.go b/internal/cmd/execute_gmail_messages_include_attachments_test.go new file mode 100644 index 000000000..e319b87aa --- /dev/null +++ b/internal/cmd/execute_gmail_messages_include_attachments_test.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestExecute_GmailMessagesSearch_IncludeAttachments(t *testing.T) { + var sawFormat, sawFields string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case strings.Contains(path, "/users/me/messages") && !strings.Contains(path, "/users/me/messages/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "messages": []map[string]any{{"id": "m1", "threadId": "t1"}}, + }) + case strings.Contains(path, "/users/me/messages/m1"): + sawFormat = r.URL.Query().Get("format") + sawFields = r.URL.Query().Get("fields") + w.Header().Set("Content-Type", "application/json") + // invoice.pdf sits three MIME levels down, to exercise deep nesting. + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "m1", "threadId": "t1", "labelIds": []string{"INBOX"}, + "payload": map[string]any{ + "mimeType": "multipart/mixed", + "headers": []map[string]any{ + {"name": "From", "value": "Example "}, + {"name": "Subject", "value": "Receipt"}, + }, + "parts": []map[string]any{ + { + "mimeType": "text/plain", + "body": map[string]any{"data": encodeBase64URL("secret body text")}, + }, + { + "mimeType": "multipart/related", + "parts": []map[string]any{{ + "mimeType": "multipart/alternative", + "parts": []map[string]any{{ + "filename": "invoice.pdf", + "mimeType": "application/pdf", + "body": map[string]any{"attachmentId": "att-pdf", "size": 4096}, + }}, + }}, + }, + }, + }, + }) + case strings.Contains(path, "/users/me/labels"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{{"id": "INBOX", "name": "INBOX", "type": "system"}}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc := newGmailServiceFromServer(t, srv) + + type searchOut struct { + Messages []struct { + Body string `json:"body"` + Attachments []struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + } `json:"attachments"` + } `json:"messages"` + } + + // --include-attachments lists the attachments but does not render the body. + res := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "messages", "search", "from:example.com", "--include-attachments"}, + svc) + if res.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", res.err, res.stderr) + } + var parsed searchOut + if err := json.Unmarshal([]byte(res.stdout), &parsed); err != nil { + t.Fatalf("decode: %v\nout=%q", err, res.stdout) + } + if len(parsed.Messages) != 1 || len(parsed.Messages[0].Attachments) != 1 { + t.Fatalf("expected one attachment, got: %#v", parsed.Messages) + } + att := parsed.Messages[0].Attachments[0] + if att.Filename != "invoice.pdf" || att.MimeType != "application/pdf" || att.Size != 4096 { + t.Fatalf("unexpected attachment: %#v", att) + } + if parsed.Messages[0].Body != "" || strings.Contains(res.stdout, "secret body text") { + t.Fatalf("body must not be included with --include-attachments: %q", res.stdout) + } + // Fetched as a complete format=full with no capping parts mask, which is what + // lets the attachment nested three levels down be listed at all. + if sawFormat != "full" || strings.Contains(sawFields, "parts(") { + t.Fatalf("include-attachments must fetch format=full without a capping parts mask; format=%q fields=%q", sawFormat, sawFields) + } + + // Default search lists neither body nor attachments. + plain := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "messages", "search", "from:example.com"}, + svc) + if plain.err != nil { + t.Fatalf("Execute plain: %v\nstderr=%q", plain.err, plain.stderr) + } + if strings.Contains(plain.stdout, "invoice.pdf") || strings.Contains(plain.stdout, "attachments") { + t.Fatalf("default search must not list attachments: %q", plain.stdout) + } +} diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index cb4af9efe..3ff0bd2f4 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -27,16 +27,17 @@ type GmailMessagesCmd struct { } type GmailMessagesSearchCmd struct { - Query []string `arg:"" name:"query" help:"Search query"` - Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` - Page string `name:"page" aliases:"cursor" help:"Page token"` - All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` - FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` - Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` - Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` - IncludeBody bool `name:"include-body" help:"Include decoded message body (JSON is full; text output truncates only unusually large bodies)"` - BodyFormat string `name:"body-format" help:"Body format preference when --include-body is set: text or html" default:"text" enum:"text,html"` - Full bool `name:"full" help:"Show full message bodies without truncation (implies --include-body)"` + Query []string `arg:"" name:"query" help:"Search query"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` + Page string `name:"page" aliases:"cursor" help:"Page token"` + All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` + Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` + Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` + IncludeBody bool `name:"include-body" help:"Include decoded message body (JSON is full; text output truncates only unusually large bodies)"` + BodyFormat string `name:"body-format" help:"Body format preference when --include-body is set: text or html" default:"text" enum:"text,html"` + Full bool `name:"full" help:"Show full message bodies without truncation (implies --include-body)"` + IncludeAttachments bool `name:"include-attachments" env:"GOG_GMAIL_INCLUDE_ATTACHMENTS" help:"Include each message's attachment metadata"` } func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -99,7 +100,7 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return err } - items, err := fetchMessageDetails(ctx, svc, messages, idToName, loc, c.IncludeBody, c.BodyFormat) + items, err := fetchMessageDetails(ctx, svc, messages, idToName, loc, c.IncludeBody, c.BodyFormat, c.IncludeAttachments) if err != nil { return err } @@ -120,7 +121,7 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro ctx, stdoutWriter(ctx), items, - gmailMessageColumns(c.IncludeBody, c.Full), + gmailMessageColumns(c.IncludeBody, c.IncludeAttachments, c.Full), ); err != nil { return err } @@ -209,7 +210,7 @@ type messageItem struct { Attachments []attachmentOutput `json:"attachments,omitempty"` } -func fetchMessageDetails(ctx context.Context, svc *gmail.Service, messages []*gmail.Message, idToName map[string]string, loc *time.Location, includeBody bool, bodyFormat string) ([]messageItem, error) { +func fetchMessageDetails(ctx context.Context, svc *gmail.Service, messages []*gmail.Message, idToName map[string]string, loc *time.Location, includeBody bool, bodyFormat string, includeAttachments bool) ([]messageItem, error) { preferHTML := bodyFormat == gmailMessageBodyFormatHTML if len(messages) == 0 { return nil, nil @@ -245,7 +246,7 @@ func fetchMessageDetails(ctx context.Context, svc *gmail.Service, messages []*gm } call := svc.Users.Messages.Get("me", messageID) - if includeBody { + if includeBody || includeAttachments { call = call.Format("full") } else { call = call.Format("metadata"). @@ -273,6 +274,8 @@ func fetchMessageDetails(ctx context.Context, svc *gmail.Service, messages []*gm } else { item.Body = gmailcontent.BestBodyText(msg.Payload) } + } + if includeBody || includeAttachments { item.Attachments = attachmentOutputs(collectAttachments(msg.Payload)) } diff --git a/internal/cmd/gmail_messages_test.go b/internal/cmd/gmail_messages_test.go index 28a3727f4..3435669f1 100644 --- a/internal/cmd/gmail_messages_test.go +++ b/internal/cmd/gmail_messages_test.go @@ -79,7 +79,7 @@ func TestFetchMessageDetails_NoRetryOnError(t *testing.T) { } messages := []*gmail.Message{{Id: "m1"}, {Id: "m2"}} - _, err = fetchMessageDetails(context.Background(), svc, messages, map[string]string{}, time.UTC, false, gmailMessageBodyFormatText) + _, err = fetchMessageDetails(context.Background(), svc, messages, map[string]string{}, time.UTC, false, gmailMessageBodyFormatText, false) if err == nil || !strings.Contains(err.Error(), "message m1") { t.Fatalf("expected message error, got %v", err) } diff --git a/internal/cmd/gmail_presentation.go b/internal/cmd/gmail_presentation.go index 5994dc37b..0014835cb 100644 --- a/internal/cmd/gmail_presentation.go +++ b/internal/cmd/gmail_presentation.go @@ -29,7 +29,7 @@ func gmailLabelColumns() []outfmt.Column[*gmail.Label] { } } -func gmailMessageColumns(includeBody, full bool) []outfmt.Column[messageItem] { +func gmailMessageColumns(includeBody, includeAttachments, full bool) []outfmt.Column[messageItem] { columns := []outfmt.Column[messageItem]{ {Header: "ID", Value: func(item messageItem) string { return item.ID }}, {Header: "THREAD", Value: func(item messageItem) string { return item.ThreadID }}, @@ -44,6 +44,20 @@ func gmailMessageColumns(includeBody, full bool) []outfmt.Column[messageItem] { Value: func(item messageItem) string { return sanitizeMessageBody(item.Body, full) }, }) } + if includeAttachments { + // No attachmentId: Gmail mints a fresh opaque token per fetch, so it's + // neither stable nor a usable text handle. The id lives in JSON output. + columns = append(columns, outfmt.Column[messageItem]{ + Header: "ATTACHMENTS", + Value: func(item messageItem) string { + parts := make([]string, len(item.Attachments)) + for i, a := range item.Attachments { + parts[i] = fmt.Sprintf("%s (%s, %s)", a.Filename, a.MimeType, a.SizeHuman) + } + return strings.Join(parts, ", ") + }, + }) + } return columns } diff --git a/internal/cmd/gmail_presentation_test.go b/internal/cmd/gmail_presentation_test.go index 67313681c..4ac46dae1 100644 --- a/internal/cmd/gmail_presentation_test.go +++ b/internal/cmd/gmail_presentation_test.go @@ -39,7 +39,7 @@ func TestGmailPresentationSchemas(t *testing.T) { Subject: "Receipt", Labels: []string{"INBOX", "Work"}, } - got := renderPlainTable(t, []messageItem{item}, gmailMessageColumns(false, false)) + got := renderPlainTable(t, []messageItem{item}, gmailMessageColumns(false, false, false)) assertTableOutput( t, got, @@ -54,7 +54,7 @@ func TestGmailPresentationSchemas(t *testing.T) { got := renderPlainTable(t, []messageItem{{ ID: "m1", Body: body, - }}, gmailMessageColumns(true, false)) + }}, gmailMessageColumns(true, false, false)) assertTableOutput( t, got, @@ -63,6 +63,23 @@ func TestGmailPresentationSchemas(t *testing.T) { ) }) + t.Run("messages with attachments", func(t *testing.T) { + t.Parallel() + got := renderPlainTable(t, []messageItem{{ + ID: "m1", + Attachments: []attachmentOutput{ + {Filename: "icon.png", MimeType: "image/png", SizeHuman: "1.2 KiB"}, + {Filename: "report.pdf", MimeType: "application/pdf", SizeHuman: "34 KiB"}, + }, + }}, gmailMessageColumns(false, true, false)) + assertTableOutput( + t, + got, + "ID\tTHREAD\tDATE\tFROM\tSUBJECT\tLABELS\tATTACHMENTS\n"+ + "m1\t\t\t\t\t\ticon.png (image/png, 1.2 KiB), report.pdf (application/pdf, 34 KiB)\n", + ) + }) + t.Run("threads", func(t *testing.T) { t.Parallel() got := renderPlainTable(t, []threadItem{