Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/commands/gog-gmail-messages-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br>`--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`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--local` | `bool` | | Use local timezone (default behavior, useful to override --timezone) |
Expand Down
114 changes: 114 additions & 0 deletions internal/cmd/execute_gmail_messages_include_attachments_test.go
Original file line number Diff line number Diff line change
@@ -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 <no-reply@example.com>"},
{"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)
}
}
5 changes: 1 addition & 4 deletions internal/cmd/gmail_attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,7 @@ func addInlineContent(payload map[string]any, data []byte, maxBytes int) {
}

func resolveAttachmentDest(messageID, attachmentID, outPathFlag, name, defaultDir string) (string, error) {
shortID := attachmentID
if len(shortID) > 8 {
shortID = shortID[:8]
}
shortID := shortAttachmentID(attachmentID)
safeFilename := sanitizeAttachmentFilename(name, defaultGmailAttachmentFilename)

if strings.TrimSpace(outPathFlag) == "" {
Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/gmail_attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ func attachmentLine(a attachmentOutput) string {
return fmt.Sprintf("attachment\t%s\t%s\t%s\t%s", a.Filename, a.SizeHuman, a.MimeType, a.AttachmentID)
}

// shortAttachmentID truncates the opaque attachmentId to its first 8 characters:
// a compact, filename-safe reference used in saved filenames and text listings.
// It identifies an attachment for a human; downloads still need the full id.
func shortAttachmentID(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}

func printAttachmentLines(p *ui.Printer, attachments []attachmentOutput) {
for _, a := range attachments {
p.Println(attachmentLine(a))
Expand Down
31 changes: 17 additions & 14 deletions internal/cmd/gmail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render attachments in non-JSON search output

When --include-attachments is used with the default table output or --plain, this path fetches and populates messageItem.Attachments, but the later call to gmailMessageColumns(c.IncludeBody, c.Full) never reads that field. The flag therefore performs the additional full-format API requests while producing exactly the same stdout as a search without the flag. Pass the option into the presentation layer and render the metadata, or explicitly restrict and document the flag as JSON-only.

Useful? React with 👍 / 👎.

if err != nil {
return err
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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").
Expand Down Expand Up @@ -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))
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/gmail_messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
16 changes: 15 additions & 1 deletion internal/cmd/gmail_presentation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand All @@ -44,6 +44,20 @@ func gmailMessageColumns(includeBody, full bool) []outfmt.Column[messageItem] {
Value: func(item messageItem) string { return sanitizeMessageBody(item.Body, full) },
})
}
if includeAttachments {
// filename, mimeType, size, and the short attachment id that also tags the
// saved filename, so a listed attachment maps to its downloaded file.
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) %s", a.Filename, a.MimeType, a.SizeHuman, shortAttachmentID(a.AttachmentID))
}
return strings.Join(parts, ", ")
},
})
}
return columns
}

Expand Down
22 changes: 20 additions & 2 deletions internal/cmd/gmail_presentation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -63,6 +63,24 @@ 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", AttachmentID: "ABCDEFGHIJKLMNOP"},
{Filename: "report.pdf", MimeType: "application/pdf", SizeHuman: "34 KiB", AttachmentID: "0123456789XYZ"},
},
}}, gmailMessageColumns(false, true, false))
// The column shows the short id (first 8 chars), matching the saved filename.
assertTableOutput(
t,
got,
"ID\tTHREAD\tDATE\tFROM\tSUBJECT\tLABELS\tATTACHMENTS\n"+
"m1\t\t\t\t\t\ticon.png (image/png, 1.2 KiB) ABCDEFGH, report.pdf (application/pdf, 34 KiB) 01234567\n",
)
})

t.Run("threads", func(t *testing.T) {
t.Parallel()
got := renderPlainTable(t, []threadItem{
Expand Down
5 changes: 1 addition & 4 deletions internal/cmd/gmail_thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,7 @@ func downloadAttachment(ctx context.Context, svc *gmail.Service, messageID strin
if strings.TrimSpace(dir) == "" {
dir = "."
}
shortID := a.AttachmentID
if len(shortID) > 8 {
shortID = shortID[:8]
}
shortID := shortAttachmentID(a.AttachmentID)
// Sanitize filename to prevent path traversal attacks
safeFilename := filepath.Base(a.Filename)
if safeFilename == "" || safeFilename == "." || safeFilename == ".." {
Expand Down