diff --git a/docs/commands/gog-gmail-attachment.md b/docs/commands/gog-gmail-attachment.md index 0aa75df10..b9f4da0c5 100644 --- a/docs/commands/gog-gmail-attachment.md +++ b/docs/commands/gog-gmail-attachment.md @@ -40,6 +40,7 @@ gog gmail (mail,email) attachment [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`
`--pick`
`--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`
`--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 | diff --git a/docs/commands/gog-gmail-drafts-get.md b/docs/commands/gog-gmail-drafts-get.md index bb0a60913..3c1589220 100644 --- a/docs/commands/gog-gmail-drafts-get.md +++ b/docs/commands/gog-gmail-drafts-get.md @@ -37,6 +37,7 @@ gog gmail (mail,email) drafts (draft) get (info,show) [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`
`--pick`
`--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`
`--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 | diff --git a/docs/commands/gog-gmail-get.md b/docs/commands/gog-gmail-get.md index 8e2773caf..4955bc311 100644 --- a/docs/commands/gog-gmail-get.md +++ b/docs/commands/gog-gmail-get.md @@ -39,6 +39,7 @@ gog gmail (mail,email) get (info,show) [flags] | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--sanitize-content`
`--safe`
`--sanitize` | `bool` | | Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON | | `--select`
`--pick`
`--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`
`--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 | diff --git a/docs/commands/gog-gmail-messages-search.md b/docs/commands/gog-gmail-messages-search.md index 1114e6d71..1e8897dd8 100644 --- a/docs/commands/gog-gmail-messages-search.md +++ b/docs/commands/gog-gmail-messages-search.md @@ -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`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | | `-z`
`--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`
`--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 | diff --git a/docs/commands/gog-gmail-thread-attachments.md b/docs/commands/gog-gmail-thread-attachments.md index 6f3cfba8c..15755c316 100644 --- a/docs/commands/gog-gmail-thread-attachments.md +++ b/docs/commands/gog-gmail-thread-attachments.md @@ -38,6 +38,7 @@ gog gmail (mail,email) thread (threads,read) attachments (files) [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`
`--pick`
`--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`
`--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 | diff --git a/docs/commands/gog-gmail-thread-get.md b/docs/commands/gog-gmail-thread-get.md index 80708f1f4..047da7cd8 100644 --- a/docs/commands/gog-gmail-thread-get.md +++ b/docs/commands/gog-gmail-thread-get.md @@ -40,6 +40,7 @@ gog gmail (mail,email) thread (threads,read) get (info,show) [flags] | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--sanitize-content`
`--safe`
`--sanitize` | `bool` | | Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON | | `--select`
`--pick`
`--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`
`--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 | diff --git a/internal/cmd/execute_gmail_attachment_index_test.go b/internal/cmd/execute_gmail_attachment_index_test.go new file mode 100644 index 000000000..0fc38073c --- /dev/null +++ b/internal/cmd/execute_gmail_attachment_index_test.go @@ -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) + } +} diff --git a/internal/cmd/gmail_attachment.go b/internal/cmd/gmail_attachment.go index 9e434b12a..d64bc5c2e 100644 --- a/internal/cmd/gmail_attachment.go +++ b/internal/cmd/gmail_attachment.go @@ -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) @@ -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) @@ -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 } @@ -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 { diff --git a/internal/cmd/gmail_attachments.go b/internal/cmd/gmail_attachments.go index 9c38da3a7..d116cc639 100644 --- a/internal/cmd/gmail_attachments.go +++ b/internal/cmd/gmail_attachments.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "strconv" "strings" "google.golang.org/api/gmail/v1" @@ -11,10 +12,11 @@ import ( ) type attachmentInfo struct { - Filename string - Size int64 - MimeType string - AttachmentID string + Filename string + Size int64 + MimeType string + AttachmentID string + AttachmentIndex int } const ( @@ -23,11 +25,12 @@ const ( ) type attachmentOutput struct { - Filename string `json:"filename"` - Size int64 `json:"size"` - SizeHuman string `json:"sizeHuman"` - MimeType string `json:"mimeType"` - AttachmentID string `json:"attachmentId"` + Filename string `json:"filename"` + Size int64 `json:"size"` + SizeHuman string `json:"sizeHuman"` + MimeType string `json:"mimeType"` + AttachmentID string `json:"attachmentId,omitempty"` + AttachmentIndex *int `json:"attachmentIndex,omitempty"` } type attachmentDownloadOutput struct { @@ -38,41 +41,51 @@ type attachmentDownloadOutput struct { } type attachmentDownloadSummary struct { - MessageID string `json:"messageId"` - AttachmentID string `json:"attachmentId"` - Filename string `json:"filename"` - MimeType string `json:"mimeType,omitempty"` - Size int64 `json:"size,omitempty"` - Path string `json:"path"` - Cached bool `json:"cached"` - DownloadError string `json:"error,omitempty"` + MessageID string `json:"messageId"` + AttachmentID string `json:"attachmentId,omitempty"` + AttachmentIndex *int `json:"attachmentIndex,omitempty"` + Filename string `json:"filename"` + MimeType string `json:"mimeType,omitempty"` + Size int64 `json:"size,omitempty"` + Path string `json:"path"` + Cached bool `json:"cached"` + DownloadError string `json:"error,omitempty"` } type attachmentDownloadDraftOutput struct { - MessageID string `json:"messageId"` - AttachmentID string `json:"attachmentId"` - Filename string `json:"filename"` - Path string `json:"path"` - Cached bool `json:"cached"` + MessageID string `json:"messageId"` + AttachmentID string `json:"attachmentId,omitempty"` + AttachmentIndex *int `json:"attachmentIndex,omitempty"` + Filename string `json:"filename"` + Path string `json:"path"` + Cached bool `json:"cached"` } -func attachmentOutputFromInfo(a attachmentInfo) attachmentOutput { - return attachmentOutput{ - Filename: a.Filename, - Size: a.Size, - SizeHuman: formatBytes(a.Size), - MimeType: a.MimeType, - AttachmentID: a.AttachmentID, +// attachmentOutputFromInfo builds the display output for an attachment. In indexed +// mode it emits the attachment's 0-based index (a number) in place of the real +// (long, opaque) attachmentId; the real id still drives the actual download. +func attachmentOutputFromInfo(a attachmentInfo, useIndexedAttachmentIDs bool) attachmentOutput { + out := attachmentOutput{ + Filename: a.Filename, + Size: a.Size, + SizeHuman: formatBytes(a.Size), + MimeType: a.MimeType, } + if useIndexedAttachmentIDs { + out.AttachmentIndex = &a.AttachmentIndex + } else { + out.AttachmentID = a.AttachmentID + } + return out } -func attachmentOutputs(attachments []attachmentInfo) []attachmentOutput { +func attachmentOutputs(attachments []attachmentInfo, useIndexedAttachmentIDs bool) []attachmentOutput { if len(attachments) == 0 { return nil } out := make([]attachmentOutput, len(attachments)) for i, a := range attachments { - out[i] = attachmentOutputFromInfo(a) + out[i] = attachmentOutputFromInfo(a, useIndexedAttachmentIDs) } return out } @@ -88,7 +101,7 @@ func attachmentOutputsFromDownloads(attachments []attachmentDownloadOutput) []at return out } -func attachmentDownloadOutputsFromInfo(messageID string, attachments []attachmentInfo) []attachmentDownloadOutput { +func attachmentDownloadOutputsFromInfo(messageID string, attachments []attachmentInfo, useIndexedAttachmentIDs bool) []attachmentDownloadOutput { if len(attachments) == 0 { return nil } @@ -96,7 +109,7 @@ func attachmentDownloadOutputsFromInfo(messageID string, attachments []attachmen for i, a := range attachments { out[i] = attachmentDownloadOutput{ MessageID: messageID, - attachmentOutput: attachmentOutputFromInfo(a), + attachmentOutput: attachmentOutputFromInfo(a, useIndexedAttachmentIDs), } } return out @@ -109,13 +122,14 @@ func attachmentDownloadSummaries(attachments []attachmentDownloadOutput) []attac out := make([]attachmentDownloadSummary, len(attachments)) for i, a := range attachments { out[i] = attachmentDownloadSummary{ - MessageID: a.MessageID, - AttachmentID: a.AttachmentID, - Filename: a.Filename, - MimeType: a.MimeType, - Size: a.Size, - Path: a.Path, - Cached: a.Cached, + MessageID: a.MessageID, + AttachmentID: a.AttachmentID, + AttachmentIndex: a.AttachmentIndex, + Filename: a.Filename, + MimeType: a.MimeType, + Size: a.Size, + Path: a.Path, + Cached: a.Cached, } } return out @@ -128,18 +142,23 @@ func attachmentDownloadDraftOutputs(attachments []attachmentDownloadOutput) []at out := make([]attachmentDownloadDraftOutput, len(attachments)) for i, a := range attachments { out[i] = attachmentDownloadDraftOutput{ - MessageID: a.MessageID, - AttachmentID: a.AttachmentID, - Filename: a.Filename, - Path: a.Path, - Cached: a.Cached, + MessageID: a.MessageID, + AttachmentID: a.AttachmentID, + AttachmentIndex: a.AttachmentIndex, + Filename: a.Filename, + Path: a.Path, + Cached: a.Cached, } } return out } 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) + ref := a.AttachmentID + if a.AttachmentIndex != nil { + ref = strconv.Itoa(*a.AttachmentIndex) + } + return fmt.Sprintf("attachment\t%s\t%s\t%s\t%s", a.Filename, a.SizeHuman, a.MimeType, ref) } func printAttachmentLines(p *ui.Printer, attachments []attachmentOutput) { @@ -148,8 +167,8 @@ func printAttachmentLines(p *ui.Printer, attachments []attachmentOutput) { } } -func printAttachmentSection(p *ui.Printer, attachments []attachmentInfo) { - out := attachmentOutputs(attachments) +func printAttachmentSection(p *ui.Printer, attachments []attachmentInfo, useIndexedAttachmentIDs bool) { + out := attachmentOutputs(attachments, useIndexedAttachmentIDs) if len(out) == 0 { return } @@ -158,19 +177,19 @@ func printAttachmentSection(p *ui.Printer, attachments []attachmentInfo) { p.Println("") } -func downloadAttachmentOutputs(ctx context.Context, svc *gmail.Service, messageID string, attachments []attachmentInfo, dir string) ([]attachmentDownloadOutput, error) { +func downloadAttachmentOutputs(ctx context.Context, svc *gmail.Service, messageID string, attachments []attachmentInfo, dir string, useIndexedAttachmentIDs bool) ([]attachmentDownloadOutput, error) { if len(attachments) == 0 { return nil, nil } out := make([]attachmentDownloadOutput, 0, len(attachments)) for _, a := range attachments { - outPath, cached, err := downloadAttachment(ctx, svc, messageID, a, dir) + outPath, cached, err := downloadAttachment(ctx, svc, messageID, a, dir, useIndexedAttachmentIDs) if err != nil { return nil, err } out = append(out, attachmentDownloadOutput{ MessageID: messageID, - attachmentOutput: attachmentOutputFromInfo(a), + attachmentOutput: attachmentOutputFromInfo(a, useIndexedAttachmentIDs), Path: outPath, Cached: cached, }) @@ -179,6 +198,33 @@ func downloadAttachmentOutputs(ctx context.Context, svc *gmail.Service, messageI } func collectAttachments(p *gmail.MessagePart) []attachmentInfo { + out := collectAttachmentParts(p) + for i := range out { + out[i].AttachmentIndex = i + } + return out +} + +// stripAttachmentIDs blanks every attachment part's opaque attachmentId. Applied +// to a raw message before it is serialized in indexed mode so the dump omits the +// long ids; the index is carried only by the curated attachments output. +func stripAttachmentIDs(p *gmail.MessagePart) { + var walk func(part *gmail.MessagePart) + walk = func(part *gmail.MessagePart) { + if part == nil { + return + } + if part.Body != nil { + part.Body.AttachmentId = "" + } + for _, child := range part.Parts { + walk(child) + } + } + walk(p) +} + +func collectAttachmentParts(p *gmail.MessagePart) []attachmentInfo { if p == nil { return nil } @@ -196,7 +242,7 @@ func collectAttachments(p *gmail.MessagePart) []attachmentInfo { }) } for _, part := range p.Parts { - out = append(out, collectAttachments(part)...) + out = append(out, collectAttachmentParts(part)...) } return out } diff --git a/internal/cmd/gmail_attachments_helpers_test.go b/internal/cmd/gmail_attachments_helpers_test.go index 658155769..91bda56ca 100644 --- a/internal/cmd/gmail_attachments_helpers_test.go +++ b/internal/cmd/gmail_attachments_helpers_test.go @@ -124,7 +124,7 @@ func TestDownloadAttachmentCached(t *testing.T) { AttachmentID: attachmentID, Size: 3, } - gotPath, cached, err := downloadAttachment(context.Background(), nil, messageID, info, dir) + gotPath, cached, err := downloadAttachment(context.Background(), nil, messageID, info, dir, false) if err != nil { t.Fatalf("downloadAttachment: %v", err) } diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index d85159c9b..81ad63853 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -99,8 +99,9 @@ func (c *GmailDraftsListCmd) Run(ctx context.Context, flags *RootFlags) error { } type GmailDraftsGetCmd struct { - DraftID string `arg:"" name:"draftId" help:"Draft ID"` - Download bool `name:"download" help:"Download draft attachments"` + DraftID string `arg:"" name:"draftId" help:"Draft ID"` + 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"` + Download bool `name:"download" help:"Download draft attachments"` } func (c *GmailDraftsGetCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -142,13 +143,19 @@ func (c *GmailDraftsGetCmd) Run(ctx context.Context, flags *RootFlags) error { if c.Download { var downloads []attachmentDownloadOutput if attachDir != "" { - downloads, err = downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir) + downloads, err = downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir, c.UseIndexedAttachmentIDs) if err != nil { return err } } out["downloaded"] = attachmentDownloadDraftOutputs(downloads) } + // The raw draft dump carries the opaque attachmentIds too; in indexed mode + // expose a curated index mapping, then strip the long ids from the raw dump. + if c.UseIndexedAttachmentIDs { + out["attachments"] = attachmentOutputs(attachments, true) + stripAttachmentIDs(msg.Payload) + } return outfmt.WriteJSON(ctx, stdoutWriter(ctx), out) } @@ -166,10 +173,10 @@ func (c *GmailDraftsGetCmd) Run(ctx context.Context, flags *RootFlags) error { u.Out().Println("") } - printAttachmentSection(u.Out(), attachments) + printAttachmentSection(u.Out(), attachments, c.UseIndexedAttachmentIDs) if attachDir != "" { - downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir) + downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir, c.UseIndexedAttachmentIDs) if err != nil { return err } diff --git a/internal/cmd/gmail_drafts_more_coverage_test.go b/internal/cmd/gmail_drafts_more_coverage_test.go index 11ed5b200..6deb4bdfc 100644 --- a/internal/cmd/gmail_drafts_more_coverage_test.go +++ b/internal/cmd/gmail_drafts_more_coverage_test.go @@ -86,6 +86,49 @@ func TestGmailDraftsGet_JSON_DownloadNoAttachments(t *testing.T) { } } +func TestGmailDraftsGet_JSON_IndexedAttachments(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "d1", + "message": map[string]any{ + "id": "m1", + "payload": map[string]any{"parts": []map[string]any{{ + "filename": "note.txt", + "mimeType": "text/plain", + "body": map[string]any{"attachmentId": "opaque-id", "size": 7}, + }}}, + }, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + result := executeWithGmailTestService(t, []string{ + "--json", "--account", "a@b.com", "gmail", "drafts", "get", "d1", "--use-indexed-attachment-ids", + }, newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("get: %v\nstderr=%q", result.err, result.stderr) + } + var payload struct { + Attachments []struct { + AttachmentIndex *int `json:"attachmentIndex"` + } `json:"attachments"` + } + if err := json.Unmarshal([]byte(result.stdout), &payload); err != nil { + t.Fatalf("decode: %v\nout=%q", err, result.stdout) + } + if len(payload.Attachments) != 1 || payload.Attachments[0].AttachmentIndex == nil || *payload.Attachments[0].AttachmentIndex != 0 { + t.Fatalf("unexpected indexed attachments: %#v", payload.Attachments) + } + if strings.Contains(result.stdout, "opaque-id") || strings.Contains(result.stdout, "attachmentId") { + t.Fatalf("indexed draft output leaked opaque id: %q", result.stdout) + } +} + func TestGmailDraftsSend_Text(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/cmd/gmail_get.go b/internal/cmd/gmail_get.go index 2d7e61896..23160b3c5 100644 --- a/internal/cmd/gmail_get.go +++ b/internal/cmd/gmail_get.go @@ -10,10 +10,11 @@ import ( ) 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)"` - SanitizeContent bool `name:"sanitize-content" aliases:"sanitize,safe" help:"Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON"` + MessageID string `arg:"" name:"messageId" help:"Message ID"` + 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"` + 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)"` + SanitizeContent bool `name:"sanitize-content" aliases:"sanitize,safe" help:"Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON"` } const ( @@ -72,7 +73,7 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error { unsubscribe := bestUnsubscribeLink(msg.Payload) if outfmt.IsJSON(ctx) { if c.SanitizeContent { - output := sanitizedGmailMessage(msg, format == gmailFormatFull) + output := sanitizedGmailMessage(msg, format == gmailFormatFull, c.UseIndexedAttachmentIDs) payload := map[string]any{ "message": output, "headers": output.Headers, @@ -110,9 +111,15 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error { if format == gmailFormatFull || format == gmailFormatMetadata { attachments := collectAttachments(msg.Payload) if len(attachments) > 0 { - payload["attachments"] = attachmentOutputs(attachments) + payload["attachments"] = attachmentOutputs(attachments, c.UseIndexedAttachmentIDs) } } + // The raw message dump carries the opaque attachmentIds too; in indexed + // mode strip them so the dump omits the long ids (the index is in the + // curated attachments output). + if c.UseIndexedAttachmentIDs { + stripAttachmentIDs(msg.Payload) + } return outfmt.WriteJSON(ctx, stdoutWriter(ctx), outfmt.PrimaryResult(payload)) } @@ -150,7 +157,7 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error { if unsubscribe != "" && !c.SanitizeContent { u.Out().Linef("unsubscribe\t%s", unsubscribe) } - attachments := attachmentOutputs(collectAttachments(msg.Payload)) + attachments := attachmentOutputs(collectAttachments(msg.Payload), c.UseIndexedAttachmentIDs) if len(attachments) > 0 { u.Out().Println("") printAttachmentLines(u.Out(), attachments) diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index 3ff0bd2f4..7833ff89f 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -27,17 +27,18 @@ 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)"` - IncludeAttachments bool `name:"include-attachments" env:"GOG_GMAIL_INCLUDE_ATTACHMENTS" help:"Include each message's attachment metadata"` + Query []string `arg:"" name:"query" help:"Search query"` + 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"` + 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 { @@ -100,7 +101,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, c.IncludeAttachments) + items, err := fetchMessageDetails(ctx, svc, messages, idToName, loc, c.IncludeBody, c.BodyFormat, c.IncludeAttachments, c.UseIndexedAttachmentIDs) if err != nil { return err } @@ -210,7 +211,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, includeAttachments bool) ([]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, useIndexedAttachmentIDs bool) ([]messageItem, error) { preferHTML := bodyFormat == gmailMessageBodyFormatHTML if len(messages) == 0 { return nil, nil @@ -276,7 +277,7 @@ func fetchMessageDetails(ctx context.Context, svc *gmail.Service, messages []*gm } } if includeBody || includeAttachments { - item.Attachments = attachmentOutputs(collectAttachments(msg.Payload)) + item.Attachments = attachmentOutputs(collectAttachments(msg.Payload), useIndexedAttachmentIDs) } if len(msg.LabelIds) > 0 { diff --git a/internal/cmd/gmail_messages_test.go b/internal/cmd/gmail_messages_test.go index 3435669f1..8b76063e1 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, false) + _, err = fetchMessageDetails(context.Background(), svc, messages, map[string]string{}, time.UTC, false, gmailMessageBodyFormatText, false, false) if err == nil || !strings.Contains(err.Error(), "message m1") { t.Fatalf("expected message error, got %v", err) } diff --git a/internal/cmd/gmail_sanitize.go b/internal/cmd/gmail_sanitize.go index 3c305ba8c..a316d50b5 100644 --- a/internal/cmd/gmail_sanitize.go +++ b/internal/cmd/gmail_sanitize.go @@ -113,7 +113,7 @@ func sanitizedGmailHeaders(p *gmail.MessagePart) map[string]string { return headers } -func sanitizedGmailMessage(msg *gmail.Message, includeBody bool) gmailSanitizedMessageOutput { +func sanitizedGmailMessage(msg *gmail.Message, includeBody bool, useIndexedAttachmentIDs bool) gmailSanitizedMessageOutput { if msg == nil { return gmailSanitizedMessageOutput{Headers: map[string]string{}} } @@ -125,7 +125,7 @@ func sanitizedGmailMessage(msg *gmail.Message, includeBody bool) gmailSanitizedM InternalDate: msg.InternalDate, SizeEstimate: msg.SizeEstimate, Headers: sanitizedGmailHeaders(msg.Payload), - Attachments: attachmentOutputs(collectAttachments(msg.Payload)), + Attachments: attachmentOutputs(collectAttachments(msg.Payload), useIndexedAttachmentIDs), } if includeBody { body, isHTML := gmailcontent.BestBodyForDisplay(msg.Payload) @@ -134,7 +134,7 @@ func sanitizedGmailMessage(msg *gmail.Message, includeBody bool) gmailSanitizedM return out } -func sanitizedGmailThread(thread *gmail.Thread, includeBody bool) gmailSanitizedThreadOutput { +func sanitizedGmailThread(thread *gmail.Thread, includeBody bool, useIndexedAttachmentIDs bool) gmailSanitizedThreadOutput { if thread == nil { return gmailSanitizedThreadOutput{Messages: []gmailSanitizedMessageOutput{}} } @@ -146,7 +146,7 @@ func sanitizedGmailThread(thread *gmail.Thread, includeBody bool) gmailSanitized if msg == nil { continue } - out.Messages = append(out.Messages, sanitizedGmailMessage(msg, includeBody)) + out.Messages = append(out.Messages, sanitizedGmailMessage(msg, includeBody, useIndexedAttachmentIDs)) } return out } diff --git a/internal/cmd/gmail_thread.go b/internal/cmd/gmail_thread.go index 98df6b00d..7d0d27e0f 100644 --- a/internal/cmd/gmail_thread.go +++ b/internal/cmd/gmail_thread.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "path/filepath" + "strconv" "strings" "google.golang.org/api/gmail/v1" @@ -23,11 +24,12 @@ type GmailThreadCmd struct { } type GmailThreadGetCmd struct { - ThreadID string `arg:"" name:"threadId" help:"Thread ID"` - Download bool `name:"download" help:"Download attachments"` - Full bool `name:"full" help:"Show full message bodies without truncation"` - SanitizeContent bool `name:"sanitize-content" aliases:"sanitize,safe" help:"Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON"` - OutputDir OutputDirFlag `embed:""` + ThreadID string `arg:"" name:"threadId" help:"Thread ID"` + 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"` + Download bool `name:"download" help:"Download attachments"` + Full bool `name:"full" help:"Show full message bodies without truncation"` + SanitizeContent bool `name:"sanitize-content" aliases:"sanitize,safe" help:"Emit agent-oriented sanitized content: strip HTML, remove HTTP(S) URLs, and omit raw Gmail payloads from JSON"` + OutputDir OutputDirFlag `embed:""` } func (c *GmailThreadGetCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -73,7 +75,7 @@ func (c *GmailThreadGetCmd) Run(ctx context.Context, flags *RootFlags) error { if msg == nil || msg.Id == "" { continue } - downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, collectAttachments(msg.Payload), attachDir) + downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, collectAttachments(msg.Payload), attachDir, c.UseIndexedAttachmentIDs) if err != nil { return err } @@ -82,14 +84,29 @@ func (c *GmailThreadGetCmd) Run(ctx context.Context, flags *RootFlags) error { } if c.SanitizeContent { return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ - "thread": sanitizedGmailThread(thread, true), + "thread": sanitizedGmailThread(thread, true, c.UseIndexedAttachmentIDs), "downloaded": downloadedFiles, }) } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + payload := map[string]any{ "thread": thread, "downloaded": downloadedFiles, - }) + } + if c.UseIndexedAttachmentIDs { + attachments := make([]attachmentDownloadOutput, 0) + if thread != nil { + for _, msg := range thread.Messages { + if msg == nil || msg.Id == "" { + continue + } + messageAttachments := collectAttachments(msg.Payload) + attachments = append(attachments, attachmentDownloadOutputsFromInfo(msg.Id, messageAttachments, true)...) + stripAttachmentIDs(msg.Payload) + } + } + payload["attachments"] = attachments + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) } if thread == nil || len(thread.Messages) == 0 { u.Err().Println("Empty thread") @@ -135,10 +152,10 @@ func (c *GmailThreadGetCmd) Run(ctx context.Context, flags *RootFlags) error { } attachments := collectAttachments(msg.Payload) - printAttachmentSection(u.Out(), attachments) + printAttachmentSection(u.Out(), attachments, c.UseIndexedAttachmentIDs) if c.Download && len(attachments) > 0 { - downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir) + downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir, c.UseIndexedAttachmentIDs) if err != nil { return err } @@ -222,9 +239,10 @@ func (c *GmailThreadModifyCmd) Run(ctx context.Context, flags *RootFlags) error // GmailThreadAttachmentsCmd lists all attachments in a thread. type GmailThreadAttachmentsCmd struct { - ThreadID string `arg:"" name:"threadId" help:"Thread ID"` - Download bool `name:"download" help:"Download all attachments"` - OutputDir OutputDirFlag `embed:""` + ThreadID string `arg:"" name:"threadId" help:"Thread ID"` + 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"` + Download bool `name:"download" help:"Download all attachments"` + OutputDir OutputDirFlag `embed:""` } func (c *GmailThreadAttachmentsCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -280,14 +298,14 @@ func (c *GmailThreadAttachmentsCmd) Run(ctx context.Context, flags *RootFlags) e } attachments := collectAttachments(msg.Payload) if c.Download { - downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir) + downloads, err := downloadAttachmentOutputs(ctx, svc, msg.Id, attachments, attachDir, c.UseIndexedAttachmentIDs) if err != nil { return err } allAttachments = append(allAttachments, downloads...) continue } - allAttachments = append(allAttachments, attachmentDownloadOutputsFromInfo(msg.Id, attachments)...) + allAttachments = append(allAttachments, attachmentDownloadOutputsFromInfo(msg.Id, attachments, c.UseIndexedAttachmentIDs)...) } if outfmt.IsJSON(ctx) { @@ -346,23 +364,28 @@ func (c *GmailURLCmd) Run(ctx context.Context, flags *RootFlags) error { return nil } -func downloadAttachment(ctx context.Context, svc *gmail.Service, messageID string, a attachmentInfo, dir string) (string, bool, error) { +func downloadAttachment(ctx context.Context, svc *gmail.Service, messageID string, a attachmentInfo, dir string, useIndexedAttachmentIDs bool) (string, bool, error) { if strings.TrimSpace(messageID) == "" || strings.TrimSpace(a.AttachmentID) == "" { return "", false, errors.New("missing messageID/attachmentID") } if strings.TrimSpace(dir) == "" { dir = "." } - shortID := a.AttachmentID - if len(shortID) > 8 { - shortID = shortID[:8] + // Discriminator between a message's attachments in one dir: the 0-based index + // in indexed mode, else the opaque id truncated to 8 chars. + ref := a.AttachmentID + if len(ref) > 8 { + ref = ref[:8] + } + if useIndexedAttachmentIDs { + ref = strconv.Itoa(a.AttachmentIndex) } // Sanitize filename to prevent path traversal attacks safeFilename := filepath.Base(a.Filename) if safeFilename == "" || safeFilename == "." || safeFilename == ".." { safeFilename = "attachment" } - filename := fmt.Sprintf("%s_%s_%s", messageID, shortID, safeFilename) + filename := fmt.Sprintf("%s_%s_%s", messageID, ref, safeFilename) outPath := filepath.Join(dir, filename) path, cached, _, err := downloadAttachmentToPath(ctx, svc, messageID, a.AttachmentID, outPath, a.Size) if err != nil { diff --git a/internal/cmd/gmail_thread_helpers_more_test.go b/internal/cmd/gmail_thread_helpers_more_test.go index cf1feb825..3d302f111 100644 --- a/internal/cmd/gmail_thread_helpers_more_test.go +++ b/internal/cmd/gmail_thread_helpers_more_test.go @@ -68,7 +68,7 @@ func TestCollectAttachmentsNil(t *testing.T) { } func TestDownloadAttachment_ErrorsAndSafeFilename(t *testing.T) { - if _, _, err := downloadAttachment(context.Background(), nil, "", attachmentInfo{AttachmentID: "a"}, "."); err == nil { + if _, _, err := downloadAttachment(context.Background(), nil, "", attachmentInfo{AttachmentID: "a"}, ".", false); err == nil { t.Fatalf("expected missing messageID error") } @@ -83,7 +83,29 @@ func TestDownloadAttachment_ErrorsAndSafeFilename(t *testing.T) { if err := os.WriteFile(expectedPath, []byte("data"), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } - path, cached, err := downloadAttachment(context.Background(), nil, "m1", att, dir) + path, cached, err := downloadAttachment(context.Background(), nil, "m1", att, dir, false) + if err != nil { + t.Fatalf("downloadAttachment: %v", err) + } + if path != expectedPath || !cached { + t.Fatalf("unexpected download result: path=%q cached=%v", path, cached) + } +} + +func TestDownloadAttachment_IndexedFilenameUsesIndex(t *testing.T) { + dir := t.TempDir() + att := attachmentInfo{ + Filename: "report.pdf", + Size: 4, + AttachmentID: "attachment1234567", + AttachmentIndex: 2, + } + // In indexed mode the saved filename embeds the 0-based index, not the id. + expectedPath := filepath.Join(dir, "m1_2_report.pdf") + if err := os.WriteFile(expectedPath, []byte("data"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + path, cached, err := downloadAttachment(context.Background(), nil, "m1", att, dir, true) if err != nil { t.Fatalf("downloadAttachment: %v", err) } @@ -112,7 +134,7 @@ func TestDownloadAttachment_ServiceError(t *testing.T) { Size: 1, AttachmentID: "att1", } - if _, _, err := downloadAttachment(context.Background(), svc, "m1", att, t.TempDir()); err == nil { + if _, _, err := downloadAttachment(context.Background(), svc, "m1", att, t.TempDir(), false); err == nil { t.Fatalf("expected error") } } diff --git a/internal/cmd/gmail_thread_run_test.go b/internal/cmd/gmail_thread_run_test.go index ad8c5ad27..f443673b4 100644 --- a/internal/cmd/gmail_thread_run_test.go +++ b/internal/cmd/gmail_thread_run_test.go @@ -118,6 +118,29 @@ func TestGmailThreadGetAndAttachments_JSON(t *testing.T) { t.Fatalf("expected downloaded file: %v", statErr) } + indexedResult := executeWithGmailTestService(t, []string{ + "--json", "--account", "a@b.com", "gmail", "thread", "get", "t1", "--use-indexed-attachment-ids", + }, svc) + if indexedResult.err != nil { + t.Fatalf("Execute indexed thread get: %v\nstderr=%q", indexedResult.err, indexedResult.stderr) + } + var indexedPayload struct { + Attachments []struct { + MessageID string `json:"messageId"` + AttachmentIndex *int `json:"attachmentIndex"` + } `json:"attachments"` + } + if err := json.Unmarshal([]byte(indexedResult.stdout), &indexedPayload); err != nil { + t.Fatalf("decode indexed thread json: %v", err) + } + if len(indexedPayload.Attachments) != 1 || indexedPayload.Attachments[0].MessageID != "m1" || + indexedPayload.Attachments[0].AttachmentIndex == nil || *indexedPayload.Attachments[0].AttachmentIndex != 0 { + t.Fatalf("unexpected indexed thread attachments: %#v", indexedPayload.Attachments) + } + if strings.Contains(indexedResult.stdout, "att1") || strings.Contains(indexedResult.stdout, "attachmentId") { + t.Fatalf("indexed thread output leaked opaque id: %q", indexedResult.stdout) + } + attachmentsResult := executeWithGmailTestService(t, []string{"--json", "--account", "a@b.com", "gmail", "thread", "attachments", "t1"}, svc) if attachmentsResult.err != nil { t.Fatalf("Execute attachments: %v\nstderr=%q", attachmentsResult.err, attachmentsResult.stderr)