Skip to content
Merged
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-attachment.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ gog gmail (mail,email) attachment <messageId> <attachmentId> [flags]
| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--use-indexed-attachment-ids` | `bool` | | Use 0-based indexes as attachment ids everywhere (output, the download argument, and saved filenames) |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-gmail-drafts-get.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ gog gmail (mail,email) drafts (draft) get (info,show) <draftId> [flags]
| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--use-indexed-attachment-ids` | `bool` | | Use 0-based indexes as attachment ids everywhere (output, the download argument, and saved filenames) |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-gmail-get.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ gog gmail (mail,email) get (info,show) <messageId> [flags]
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--sanitize-content`<br>`--safe`<br>`--sanitize` | `bool` | | Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--use-indexed-attachment-ids` | `bool` | | Use 0-based indexes as attachment ids everywhere (output, the download argument, and saved filenames) |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
Expand Down
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 @@ -46,6 +46,7 @@ gog gmail (mail,email) messages (message,msg,msgs) search (find,query,ls,list) <
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `-z`<br>`--timezone` | `string` | | Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local |
| `--use-indexed-attachment-ids` | `bool` | | Use 0-based indexes as attachment ids everywhere (output, the download argument, and saved filenames) |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-gmail-thread-attachments.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ gog gmail (mail,email) thread (threads,read) attachments (files) <threadId> [fla
| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--use-indexed-attachment-ids` | `bool` | | Use 0-based indexes as attachment ids everywhere (output, the download argument, and saved filenames) |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-gmail-thread-get.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ gog gmail (mail,email) thread (threads,read) get (info,show) <threadId> [flags]
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--sanitize-content`<br>`--safe`<br>`--sanitize` | `bool` | | Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--use-indexed-attachment-ids` | `bool` | | Use 0-based indexes as attachment ids everywhere (output, the download argument, and saved filenames) |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
Expand Down
218 changes: 218 additions & 0 deletions internal/cmd/execute_gmail_attachment_index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package cmd

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"path/filepath"
"strings"
"testing"
)

func TestAttachmentByIndex(t *testing.T) {
svc := newGmailAttachmentTestService(t, []byte("x"), "doc.pdf", "application/pdf")
ctx := context.Background()

att, err := attachmentByIndex(ctx, svc, "m1", 0)
if err != nil {
t.Fatalf("index 0: %v", err)
}
if att.AttachmentID != "a1" {
t.Fatalf("attachmentId = %q, want a1", att.AttachmentID)
}

if _, err := attachmentByIndex(ctx, svc, "m1", 3); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Fatalf("out-of-range err = %v", err)
}

// A negative index is rejected before any fetch.
if _, err := attachmentByIndex(ctx, svc, "m1", -1); err == nil || !strings.Contains(err.Error(), ">= 0") {
t.Fatalf("negative err = %v", err)
}
}

func TestAttachmentOutputs_IndexedMode(t *testing.T) {
atts := []attachmentInfo{
{AttachmentIndex: 0, Filename: "a.pdf", Size: 10, MimeType: "application/pdf", AttachmentID: "LONGID-AAA"},
{AttachmentIndex: 1, Filename: "b.png", Size: 20, MimeType: "image/png", AttachmentID: "LONGID-BBB"},
}

plain := attachmentOutputs(atts, false)
if plain[0].AttachmentID != "LONGID-AAA" || plain[1].AttachmentID != "LONGID-BBB" {
t.Fatalf("default mode should keep real ids: %#v", plain)
}

indexed := attachmentOutputs(atts, true)
if indexed[0].AttachmentIndex == nil || *indexed[0].AttachmentIndex != 0 ||
indexed[1].AttachmentIndex == nil || *indexed[1].AttachmentIndex != 1 {
t.Fatalf("indexed mode should surface positions: %#v", indexed)
}
if indexed[0].AttachmentID != "" {
t.Fatalf("indexed mode must not emit the real id: %#v", indexed[0])
}
if indexed[0].Filename != "a.pdf" || indexed[1].Size != 20 {
t.Fatalf("indexed mode must not touch non-id fields: %#v", indexed)
}
}

func TestExecute_Gmail_IndexedAttachmentIDs_FromEnv(t *testing.T) {
// The env var enables indexed mode without the CLI flag (kong reads the env tag).
t.Setenv("GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS", "1")
svc := newGmailAttachmentTestService(t, []byte("data"), "doc.pdf", "application/pdf")
result := executeWithGmailTestService(t, []string{
"--json", "--account", "a@b.com", "gmail", "get", "m1",
}, svc)
if result.err != nil {
t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
}
var parsed struct {
Attachments []struct {
AttachmentIndex *int `json:"attachmentIndex"`
} `json:"attachments"`
}
if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
t.Fatalf("decode: %v\nout=%q", err, result.stdout)
}
if len(parsed.Attachments) != 1 || parsed.Attachments[0].AttachmentIndex == nil || *parsed.Attachments[0].AttachmentIndex != 0 {
t.Fatalf("env var should enable indexed ids: %#v", parsed.Attachments)
}
}

func TestExecute_GmailAttachment_IndexResolvesToAttachment(t *testing.T) {
data := []byte("index-download-content")
svc := newGmailAttachmentTestService(t, data, "doc.pdf", "application/pdf")

// In indexed mode, index 0 resolves to the message's first attachment (a1).
parsed := executeGmailAttachmentJSON(t, svc,
"--json", "--account", "a@b.com",
"gmail", "attachment", "--use-indexed-attachment-ids", "m1", "0",
"--out", tempFilePath(t, "doc.pdf"), "--inline",
)
decoded, err := base64.StdEncoding.DecodeString(parsed["contentBase64"].(string))
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(decoded, data) {
t.Fatalf("content = %q, want %q", decoded, data)
}
}

func TestExecute_GmailAttachment_IndexOutOfRange(t *testing.T) {
svc := newGmailAttachmentTestService(t, []byte("x"), "doc.pdf", "application/pdf")
result := executeWithGmailTestService(t, []string{
"--json", "--account", "a@b.com",
"gmail", "attachment", "--use-indexed-attachment-ids", "m1", "5",
"--out", tempFilePath(t, "doc.pdf"),
}, svc)
if result.err == nil || !strings.Contains(result.err.Error(), "out of range") {
t.Fatalf("err = %v, want out-of-range", result.err)
}
}

func TestExecute_GmailAttachment_IndexedModeRejectsNonIndex(t *testing.T) {
svc := newGmailAttachmentTestService(t, []byte("x"), "doc.pdf", "application/pdf")
result := executeWithGmailTestService(t, []string{
"--json", "--account", "a@b.com",
"gmail", "attachment", "--use-indexed-attachment-ids", "m1", "a1",
"--out", tempFilePath(t, "doc.pdf"),
}, svc)
if result.err == nil || !strings.Contains(result.err.Error(), "must be a 0-based index") {
t.Fatalf("err = %v, want index-only rejection", result.err)
}
}

func TestExecute_GmailAttachment_IndexedDryRunValidatesLocally(t *testing.T) {
outPath := filepath.Join(t.TempDir(), "attachment.bin")
invalid := executeWithTestRuntime(t, []string{
"--json", "--dry-run", "gmail", "attachment", "--use-indexed-attachment-ids",
"m1", "not-an-index", "--out", outPath,
}, nil)
if invalid.err == nil || !strings.Contains(invalid.err.Error(), "must be a 0-based index") {
t.Fatalf("invalid dry-run err = %v, want index validation", invalid.err)
}

valid := executeWithTestRuntime(t, []string{
"--json", "--dry-run", "gmail", "attachment", "--use-indexed-attachment-ids",
"m1", "0", "--out", outPath,
}, nil)
if valid.err != nil {
t.Fatalf("valid dry-run: %v\nstderr=%q", valid.err, valid.stderr)
}
var plan struct {
Request struct {
AttachmentIndex *int `json:"attachment_index"`
AttachmentID string `json:"attachment_id"`
} `json:"request"`
}
if err := json.Unmarshal([]byte(valid.stdout), &plan); err != nil {
t.Fatalf("decode dry-run: %v\nout=%q", err, valid.stdout)
}
if plan.Request.AttachmentIndex == nil || *plan.Request.AttachmentIndex != 0 || plan.Request.AttachmentID != "" {
t.Fatalf("unexpected indexed dry-run plan: %#v", plan.Request)
}
}

func TestExecute_GmailAttachment_IndexedMode_FilenameUsesIndex(t *testing.T) {
dir := t.TempDir()
svc := newGmailAttachmentTestService(t, []byte("data"), "doc.pdf", "application/pdf")
parsed := executeGmailAttachmentJSON(t, svc,
"--json", "--account", "a@b.com",
"gmail", "attachment", "--use-indexed-attachment-ids", "m1", "0", "--out", dir,
)
if path, _ := parsed["path"].(string); !strings.HasSuffix(path, "m1_0_attachment.bin") {
t.Fatalf("indexed-mode default filename must embed the index, got path=%q", path)
}
}

func TestExecute_GmailAttachment_NumericArgIsRawIDWithoutFlag(t *testing.T) {
// Without the flag, "0" is a (real) attachmentId, not an index: the test server
// only serves a1, so downloading id "0" fails rather than resolving to index 0.
svc := newGmailAttachmentTestService(t, []byte("x"), "doc.pdf", "application/pdf")
result := executeWithGmailTestService(t, []string{
"--json", "--account", "a@b.com",
"gmail", "attachment", "m1", "0",
"--out", tempFilePath(t, "doc.pdf"),
}, svc)
if result.err == nil {
t.Fatalf("expected download of raw id %q to fail, got success", "0")
}
}

func TestExecute_GmailGet_IndexedAttachmentIDs(t *testing.T) {
svc := newGmailAttachmentTestService(t, []byte("data"), "doc.pdf", "application/pdf")
result := executeWithGmailTestService(t, []string{
"--json", "--account", "a@b.com",
"gmail", "get", "--use-indexed-attachment-ids", "m1",
}, svc)
if result.err != nil {
t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
}
var parsed struct {
Message struct {
Payload struct {
Parts []struct {
Body struct {
AttachmentID string `json:"attachmentId"`
} `json:"body"`
} `json:"parts"`
} `json:"payload"`
} `json:"message"`
Attachments []struct {
AttachmentIndex *int `json:"attachmentIndex"`
} `json:"attachments"`
}
if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
t.Fatalf("decode: %v\nout=%q", err, result.stdout)
}
if len(parsed.Attachments) != 1 || parsed.Attachments[0].AttachmentIndex == nil || *parsed.Attachments[0].AttachmentIndex != 0 {
t.Fatalf("gmail get should surface index in indexed mode: %#v", parsed.Attachments)
}
// The raw message dump must omit the opaque id in indexed mode.
if len(parsed.Message.Payload.Parts) != 1 || parsed.Message.Payload.Parts[0].Body.AttachmentID != "" {
t.Fatalf("raw message dump should omit the attachmentId: %#v", parsed.Message.Payload.Parts)
}
if strings.Contains(result.stdout, "a1") {
t.Fatalf("indexed mode must not leak the opaque attachmentId anywhere: out=%q", result.stdout)
}
}
64 changes: 55 additions & 9 deletions internal/cmd/gmail_attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,35 @@ import (
)

type GmailAttachmentCmd struct {
MessageID string `arg:"" name:"messageId" help:"Message ID"`
AttachmentID string `arg:"" name:"attachmentId" help:"Attachment ID"`
Output OutputPathFlag `embed:""`
Name string `name:"name" help:"Filename (used when --out is empty or points to a directory)"`
Inline bool `name:"inline" help:"Also return the attachment content base64-encoded (contentBase64) in the response; attachments over the inline size limit fall back to the file path with an explanatory reason"`
InlineMaxBytes int `name:"inline-max-bytes" default:"3145728" help:"Maximum attachment size --inline embeds (bytes)" env:"GOG_GMAIL_INLINE_MAX_BYTES"`
MessageID string `arg:"" name:"messageId" help:"Message ID"`
AttachmentID string `arg:"" name:"attachmentId" help:"Attachment ID, or a 0-based index with --use-indexed-attachment-ids"`
UseIndexedAttachmentIDs bool `name:"use-indexed-attachment-ids" help:"Use 0-based indexes as attachment ids everywhere (output, the download argument, and saved filenames)" env:"GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS"`
Output OutputPathFlag `embed:""`
Name string `name:"name" help:"Filename (used when --out is empty or points to a directory)"`
Inline bool `name:"inline" help:"Also return the attachment content base64-encoded (contentBase64) in the response; attachments over the inline size limit fall back to the file path with an explanatory reason"`
InlineMaxBytes int `name:"inline-max-bytes" default:"3145728" help:"Maximum attachment size --inline embeds (bytes)" env:"GOG_GMAIL_INLINE_MAX_BYTES"`
}

const defaultGmailAttachmentFilename = "attachment.bin"

// attachmentByIndex resolves a 0-based index into a message's attachments to the
// attachment itself. The index is a stable, compact reference because a message's
// MIME structure is fixed, unlike the long opaque attachmentId.
func attachmentByIndex(ctx context.Context, svc *gmail.Service, messageID string, idx int) (attachmentInfo, error) {
if idx < 0 {
return attachmentInfo{}, usagef("attachment index must be >= 0, got %d", idx)
}
msg, err := svc.Users.Messages.Get("me", messageID).Format("full").Context(ctx).Do()
if err != nil {
return attachmentInfo{}, fmt.Errorf("resolve attachment index %d: %w", idx, err)
}
atts := collectAttachments(msg.Payload)
if idx >= len(atts) {
return attachmentInfo{}, usagef("attachment index %d out of range: message has %d attachment(s)", idx, len(atts))
}
return atts[idx], nil
}

func printAttachmentDownloadResult(ctx context.Context, u *ui.UI, payload map[string]any) error {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload)
Expand Down Expand Up @@ -62,6 +81,17 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
if c.InlineMaxBytes < 0 {
return usage("--inline-max-bytes must be non-negative")
}
attachmentIndex := -1
if c.UseIndexedAttachmentIDs {
parsedIndex, parseErr := strconv.Atoi(attachmentID)
if parseErr != nil {
return usagef("with --use-indexed-attachment-ids, the attachment argument must be a 0-based index, got %q", attachmentID)
}
attachmentIndex = parsedIndex
if attachmentIndex < 0 {
return usagef("attachment index must be >= 0, got %d", attachmentIndex)
}
}
defaultDir := ""
if strings.TrimSpace(c.Output.Path) == "" {
layout, err := commandLayout(ctx, config.PathKindConfig)
Expand All @@ -76,13 +106,18 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
}

// Avoid touching auth/keyring and avoid writing files in dry-run mode.
if dryRunErr := dryRunExit(ctx, flags, "gmail.attachment.download", map[string]any{
plan := map[string]any{
"message_id": messageID,
"attachment_id": attachmentID,
"path": dest,
"inline": c.Inline,
"inline_max_bytes": c.InlineMaxBytes,
}); dryRunErr != nil {
}
if c.UseIndexedAttachmentIDs {
plan["attachment_index"] = attachmentIndex
} else {
plan["attachment_id"] = attachmentID
}
if dryRunErr := dryRunExit(ctx, flags, "gmail.attachment.download", plan); dryRunErr != nil {
return dryRunErr
}

Expand All @@ -96,6 +131,17 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}

// In indexed mode the argument is a 0-based index; resolve it to the real id that
// drives the download, keeping the index-based dest computed above. Without the flag
// the argument is the attachmentId itself and is used unchanged.
if c.UseIndexedAttachmentIDs {
att, lookupErr := attachmentByIndex(ctx, svc, messageID, attachmentIndex)
if lookupErr != nil {
return lookupErr
}
attachmentID = att.AttachmentID
}

expectedSize := int64(-1)
var info *attachmentInfo
if c.Inline {
Expand Down
Loading