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{