diff --git a/docs/commands/gog-gmail-attachment.md b/docs/commands/gog-gmail-attachment.md index 0accf0e6d..594a05b86 100644 --- a/docs/commands/gog-gmail-attachment.md +++ b/docs/commands/gog-gmail-attachment.md @@ -30,6 +30,7 @@ gog gmail (mail,email) attachment [flags] | `--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) | +| `--inline` | `bool` | | 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 | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--name` | `string` | | Filename (used when --out is empty or points to a directory) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | @@ -38,6 +39,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. | +| `--text` | `bool` | | Also return the attachment content as extracted text (PDF, HTML, plain text) in the response | | `-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/spec.md b/docs/spec.md index 677688eb8..a1b75881f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -381,7 +381,7 @@ after the bounded retry window, the command exits with retryable code `8`. - `gog gmail thread get [--download]` - `gog gmail thread modify [--add ...] [--remove ...]` - `gog gmail get [--format full|metadata|raw] [--headers ...]` -- `gog gmail attachment [--out PATH] [--name NAME]` +- `gog gmail attachment [--out PATH] [--name NAME] [--inline] [--text]` - `gog gmail url ` - `gog gmail reply [--body B|--body-file PATH|--body-html HTML|--body-html-file PATH] [--to ...] [--cc ...] [--bcc ...] [--remove ...] [--subject S] [--no-quote] [--from addr] [--signature|--signature-from addr|--signature-file path] [--attach ...]` - `gog gmail reply-all [--body B|--body-file PATH|--body-html HTML|--body-html-file PATH] [--to ...] [--cc ...] [--bcc ...] [--remove ...] [--subject S] [--no-quote] [--from addr] [--signature|--signature-from addr|--signature-file path] [--attach ...]` diff --git a/go.mod b/go.mod index 90f4f94a0..447279ade 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,10 @@ require ( google.golang.org/api v0.286.0 ) -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) require ( cloud.google.com/go v0.123.0 // indirect diff --git a/go.sum b/go.sum index 523dd92fe..9f8a1fb40 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA= diff --git a/internal/cmd/execute_gmail_attachment_inline_test.go b/internal/cmd/execute_gmail_attachment_inline_test.go new file mode 100644 index 000000000..d1051014f --- /dev/null +++ b/internal/cmd/execute_gmail_attachment_inline_test.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/gmail/v1" +) + +func tempFilePath(t *testing.T, name string) string { + t.Helper() + return filepath.Join(t.TempDir(), name) +} + +// newGmailAttachmentTestServicePayload serves one attachment (id a1 on +// message m1) with the given messages.get payload. +func newGmailAttachmentTestServicePayload(t *testing.T, data []byte, payload map[string]any) *gmail.Service { + t.Helper() + encoded := base64.URLEncoding.EncodeToString(data) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1/attachments/a1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": encoded}) + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1") && !strings.Contains(r.URL.Path, "/attachments/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1", "payload": payload}) + default: + http.NotFound(w, r) + } + }) + svc, closeServer := newGoogleTestService(t, handler, gmail.NewService) + t.Cleanup(closeServer) + return svc +} + +// newGmailAttachmentTestService serves one attachment (id a1 on message m1) +// with the given payload metadata. +func newGmailAttachmentTestService(t *testing.T, data []byte, filename, mimeType string) *gmail.Service { + t.Helper() + return newGmailAttachmentTestServiceWithPayloadID(t, data, filename, mimeType, "a1") +} + +// newGmailAttachmentTestServiceWithPayloadID lets the messages.get payload +// carry a different attachment ID than the one being downloaded, mimicking +// Gmail's unstable attachment IDs. +func newGmailAttachmentTestServiceWithPayloadID(t *testing.T, data []byte, filename, mimeType, payloadAttachmentID string) *gmail.Service { + t.Helper() + return newGmailAttachmentTestServicePayload(t, data, map[string]any{"parts": []map[string]any{{ + "filename": filename, + "mimeType": mimeType, + "body": map[string]any{"attachmentId": payloadAttachmentID, "size": len(data)}, + }}}) +} + +// newGmailAttachmentTestServiceNoParts serves a message whose payload exposes +// no attachment parts at all. +func newGmailAttachmentTestServiceNoParts(t *testing.T, data []byte) *gmail.Service { + t.Helper() + return newGmailAttachmentTestServicePayload(t, data, map[string]any{}) +} + +func TestExecute_GmailAttachment_Inline_SmallAttachment_ReturnsBase64(t *testing.T) { + data := []byte("hello inline attachment") + svc := newGmailAttachmentTestService(t, data, "photo.png", "image/png") + outPath := tempFilePath(t, "photo.png") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", outPath, "--inline", + ) + + got, ok := parsed["contentBase64"].(string) + if !ok { + t.Fatalf("contentBase64 missing or not string: %#v", parsed) + } + decoded, err := base64.StdEncoding.DecodeString(got) + if err != nil { + t.Fatalf("contentBase64 not decodable: %v", err) + } + if !bytes.Equal(decoded, data) { + t.Fatalf("decoded=%q want=%q", decoded, data) + } + if parsed["mimeType"] != "image/png" { + t.Fatalf("mimeType=%v", parsed["mimeType"]) + } + if parsed["filename"] != "photo.png" { + t.Fatalf("filename=%v", parsed["filename"]) + } + if parsed["path"] != outPath { + t.Fatalf("path=%v", parsed["path"]) + } + if _, statErr := os.Stat(outPath); statErr != nil { + t.Fatalf("file not written: %v", statErr) + } +} + +func TestExecute_GmailAttachment_Inline_Oversized_FallsBackToPathWithReason(t *testing.T) { + data := bytes.Repeat([]byte("x"), maxInlineAttachmentBytes+1) + svc := newGmailAttachmentTestService(t, data, "big.bin", "application/octet-stream") + outPath := tempFilePath(t, "big.bin") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", outPath, "--inline", + ) + + if _, ok := parsed["contentBase64"]; ok { + t.Fatalf("oversized attachment must not be inlined: %#v", parsed) + } + reason, _ := parsed["reason"].(string) + if !strings.Contains(reason, "inline size limit") { + t.Fatalf("reason=%q", reason) + } + if parsed["path"] != outPath { + t.Fatalf("path=%v", parsed["path"]) + } + st, statErr := os.Stat(outPath) + if statErr != nil || st.Size() != int64(len(data)) { + t.Fatalf("file not written: %v size=%v", statErr, st) + } +} + +func TestExecute_GmailAttachment_Default_OutputUnchanged(t *testing.T) { + data := []byte("plain old download") + svc := newGmailAttachmentTestService(t, data, "a.txt", "text/plain") + outPath := tempFilePath(t, "a.txt") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", outPath, + ) + + for _, key := range []string{"contentBase64", "text", "reason", "mimeType", "filename"} { + if _, ok := parsed[key]; ok { + t.Fatalf("default output must not contain %q: %#v", key, parsed) + } + } + if parsed["path"] != outPath || parsed["cached"] != false || parsed["bytes"] != float64(len(data)) { + t.Fatalf("unexpected default output: %#v", parsed) + } +} + +func TestExecute_GmailAttachment_InlineAndText_Rejected(t *testing.T) { + svc := newGmailAttachmentTestService(t, []byte("x"), "a.txt", "text/plain") + result := executeWithGmailTestService(t, []string{ + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "a.txt"), "--inline", "--text", + }, svc) + if result.err == nil { + t.Fatalf("expected usage error for --inline with --text, got stdout=%q", result.stdout) + } +} diff --git a/internal/cmd/execute_gmail_attachment_text_test.go b/internal/cmd/execute_gmail_attachment_text_test.go new file mode 100644 index 000000000..c60f3264b --- /dev/null +++ b/internal/cmd/execute_gmail_attachment_text_test.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +// buildMinimalPDF assembles a tiny one-page PDF whose page content stream is +// given verbatim, computing the xref offsets so strict parsers accept it. +func buildMinimalPDF(t *testing.T, contentStream string) []byte { + t.Helper() + objects := []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(contentStream), contentStream), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + } + + var buf bytes.Buffer + buf.WriteString("%PDF-1.4\n") + offsets := make([]int, len(objects)) + for i, obj := range objects { + offsets[i] = buf.Len() + fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, obj) + } + xrefStart := buf.Len() + fmt.Fprintf(&buf, "xref\n0 %d\n", len(objects)+1) + buf.WriteString("0000000000 65535 f \n") + for _, off := range offsets { + fmt.Fprintf(&buf, "%010d 00000 n \n", off) + } + fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xrefStart) + return buf.Bytes() +} + +func TestExecute_GmailAttachment_Text_PDF_ReturnsExtractedText(t *testing.T) { + pdfData := buildMinimalPDF(t, "BT /F1 24 Tf 72 720 Td (Hello gog attachment) Tj ET") + svc := newGmailAttachmentTestService(t, pdfData, "report.pdf", "application/pdf") + outPath := tempFilePath(t, "report.pdf") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", outPath, "--text", + ) + + text, ok := parsed["text"].(string) + if !ok { + t.Fatalf("text missing: %#v", parsed) + } + if !strings.Contains(text, "Hello gog attachment") { + t.Fatalf("text=%q", text) + } + if parsed["mimeType"] != "application/pdf" { + t.Fatalf("mimeType=%v", parsed["mimeType"]) + } +} + +func TestExecute_GmailAttachment_Text_ImageOnlyPDF_NotesNoText(t *testing.T) { + // A content stream with no text operators mimics a scanned/image-only PDF. + pdfData := buildMinimalPDF(t, "q 1 0 0 1 0 0 cm Q") + svc := newGmailAttachmentTestService(t, pdfData, "scan.pdf", "application/pdf") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "scan.pdf"), "--text", + ) + + if text, ok := parsed["text"].(string); !ok || text != "" { + t.Fatalf("expected empty text, got %#v", parsed["text"]) + } + note, _ := parsed["note"].(string) + if !strings.Contains(note, "no extractable text") { + t.Fatalf("note=%q", note) + } +} + +func TestExecute_GmailAttachment_Text_HTML_StripsTags(t *testing.T) { + html := "

Invoice 42 attached

" + svc := newGmailAttachmentTestService(t, []byte(html), "invoice.html", "text/html") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "invoice.html"), "--text", + ) + + text, _ := parsed["text"].(string) + if !strings.Contains(text, "Invoice 42 attached") { + t.Fatalf("text=%q", text) + } + if strings.Contains(text, "<") || strings.Contains(text, "color:red") { + t.Fatalf("tags/styles not stripped: %q", text) + } +} + +func TestExecute_GmailAttachment_Text_PlainText_ReturnsVerbatim(t *testing.T) { + content := "line one\nline two" + svc := newGmailAttachmentTestService(t, []byte(content), "notes.txt", "text/plain") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "notes.txt"), "--text", + ) + + if parsed["text"] != content { + t.Fatalf("text=%q", parsed["text"]) + } +} + +func TestExecute_GmailAttachment_Text_UnsupportedType_ReturnsReason(t *testing.T) { + svc := newGmailAttachmentTestService(t, []byte{0x89, 'P', 'N', 'G'}, "pic.png", "image/png") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "pic.png"), "--text", + ) + + if _, ok := parsed["text"]; ok { + t.Fatalf("unexpected text for image: %#v", parsed) + } + reason, _ := parsed["reason"].(string) + if !strings.Contains(reason, "image/png") || !strings.Contains(reason, "--inline") { + t.Fatalf("reason=%q", reason) + } + if parsed["path"] == nil { + t.Fatalf("path missing: %#v", parsed) + } +} + +func TestExecute_GmailAttachment_Text_Oversized_ReturnsReason(t *testing.T) { + data := bytes.Repeat([]byte("y"), maxInlineAttachmentBytes+1) + svc := newGmailAttachmentTestService(t, data, "big.txt", "text/plain") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "big.txt"), "--text", + ) + + if _, ok := parsed["text"]; ok { + t.Fatalf("oversized attachment must not extract: %#v", parsed) + } + reason, _ := parsed["reason"].(string) + if !strings.Contains(reason, "inline size limit") { + t.Fatalf("reason=%q", reason) + } +} + +// Gmail attachment IDs are not stable across API calls, so the metadata +// lookup may not find an exact ID match. With a single attachment on the +// message, the command should still resolve filename/mimeType from it. +func TestExecute_GmailAttachment_Text_UnstableAttachmentID_FallsBackToSingleAttachment(t *testing.T) { + content := "sniff me" + svc := newGmailAttachmentTestServiceWithPayloadID(t, []byte(content), "notes.txt", "text/plain", "rotated-id") + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "notes.txt"), "--text", + ) + + if parsed["text"] != content { + t.Fatalf("text=%#v", parsed["text"]) + } + if parsed["filename"] != "notes.txt" || parsed["mimeType"] != "text/plain" { + t.Fatalf("metadata fallback missing: %#v", parsed) + } +} + +// When no part metadata resolves at all, plain text should still extract via +// content sniffing rather than reporting an unsupported mime type. +func TestExecute_GmailAttachment_Text_NoMetadata_SniffsPlainText(t *testing.T) { + content := "sniffed without metadata" + svc := newGmailAttachmentTestServiceNoParts(t, []byte(content)) + + parsed := executeGmailAttachmentJSON(t, svc, + "--json", "--account", "a@b.com", + "gmail", "attachment", "m1", "a1", + "--out", tempFilePath(t, "mystery.bin"), "--text", + ) + + if parsed["text"] != content { + t.Fatalf("text=%#v (reason=%v)", parsed["text"], parsed["reason"]) + } +} diff --git a/internal/cmd/gmail_attachment.go b/internal/cmd/gmail_attachment.go index 69a908574..2e17e86bc 100644 --- a/internal/cmd/gmail_attachment.go +++ b/internal/cmd/gmail_attachment.go @@ -21,17 +21,28 @@ type GmailAttachmentCmd struct { 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"` + Text bool `name:"text" help:"Also return the attachment content as extracted text (PDF, HTML, plain text) in the response"` } const defaultGmailAttachmentFilename = "attachment.bin" -func printAttachmentDownloadResult(ctx context.Context, u *ui.UI, path string, cached bool, bytes int64) error { +// maxInlineAttachmentBytes caps how much raw attachment content --inline and +// --text will embed in the command output. +const maxInlineAttachmentBytes = 3 << 20 + +func printAttachmentDownloadResult(ctx context.Context, u *ui.UI, payload map[string]any) error { if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{"path": path, "cached": cached, "bytes": bytes}) + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + u.Out().Linef("path\t%s", payload["path"]) + u.Out().Linef("cached\t%t", payload["cached"]) + u.Out().Linef("bytes\t%d", payload["bytes"]) + for _, key := range []string{"filename", "mimeType", "contentBase64", "text", "note", "reason"} { + if v, ok := payload[key]; ok { + u.Out().Linef("%s\t%v", key, v) + } } - u.Out().Linef("path\t%s", path) - u.Out().Linef("cached\t%t", cached) - u.Out().Linef("bytes\t%d", bytes) return nil } @@ -42,6 +53,9 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error { if messageID == "" || attachmentID == "" { return usage("messageId/attachmentId required") } + if c.Inline && c.Text { + return usage("--inline and --text are mutually exclusive") + } defaultDir := "" if strings.TrimSpace(c.Output.Path) == "" { @@ -76,7 +90,15 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error { } expectedSize := int64(-1) - if st, statErr := os.Stat(dest); statErr == nil && st.Mode().IsRegular() { + var info *attachmentInfo + if c.Inline || c.Text { + // Content modes need the part metadata (filename/mimeType); its size + // doubles as the cache-check estimate. + info = lookupAttachmentPartInfo(ctx, svc, messageID, attachmentID) + if info != nil { + expectedSize = info.Size + } + } else if st, statErr := os.Stat(dest); statErr == nil && st.Mode().IsRegular() { // Only hit messages.get when we might have a cache-hit candidate. expectedSize = lookupAttachmentSizeEstimate(ctx, svc, messageID, attachmentID) } @@ -84,7 +106,41 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - return printAttachmentDownloadResult(ctx, u, path, cached, bytes) + payload := map[string]any{"path": path, "cached": cached, "bytes": bytes} + if info != nil { + if info.Filename != "" { + payload["filename"] = info.Filename + } + if info.MimeType != "" { + payload["mimeType"] = info.MimeType + } + } + if c.Inline { + addInlineContent(payload, path, bytes) + } + if c.Text { + filename, mimeType := "", "" + if info != nil { + filename, mimeType = info.Filename, info.MimeType + } + addTextContent(payload, path, bytes, filename, mimeType) + } + return printAttachmentDownloadResult(ctx, u, payload) +} + +// addInlineContent embeds the downloaded file base64-encoded, or a reason when +// it exceeds the inline size limit. +func addInlineContent(payload map[string]any, path string, size int64) { + if size > maxInlineAttachmentBytes { + payload["reason"] = fmt.Sprintf("attachment size %d bytes exceeds inline size limit (%d bytes); content written to path only", size, maxInlineAttachmentBytes) + return + } + data, err := os.ReadFile(path) // #nosec G304 -- path is the attachment destination this command just wrote. + if err != nil { + payload["reason"] = fmt.Sprintf("failed to read downloaded attachment for inlining: %v", err) + return + } + payload["contentBase64"] = base64.StdEncoding.EncodeToString(data) } func resolveAttachmentDest(messageID, attachmentID, outPathFlag, name, defaultDir string) (string, error) { @@ -145,19 +201,36 @@ func sanitizeAttachmentFilename(name, fallback string) string { } func lookupAttachmentSizeEstimate(ctx context.Context, svc *gmail.Service, messageID, attachmentID string) int64 { - if svc == nil { + info := lookupAttachmentPartInfo(ctx, svc, messageID, attachmentID) + if info == nil || info.Size <= 0 { return -1 } + return info.Size +} + +// lookupAttachmentPartInfo fetches the message payload to resolve the part +// metadata (filename, mimeType, size) for an attachment ID. Best-effort: nil +// when the lookup fails or the attachment is not found. +func lookupAttachmentPartInfo(ctx context.Context, svc *gmail.Service, messageID, attachmentID string) *attachmentInfo { + if svc == nil { + return nil + } msg, err := svc.Users.Messages.Get("me", messageID).Format("full").Fields("payload").Context(ctx).Do() if err != nil || msg == nil { - return -1 + return nil } - for _, a := range collectAttachments(msg.Payload) { - if a.AttachmentID == attachmentID && a.Size > 0 { - return a.Size + attachments := collectAttachments(msg.Payload) + for _, a := range attachments { + if a.AttachmentID == attachmentID { + return &a } } - return -1 + // Gmail attachment IDs are not stable across API responses, so an exact + // match can miss; with a single attachment it is unambiguous anyway. + if len(attachments) == 1 { + return &attachments[0] + } + return nil } func downloadAttachmentToPath( diff --git a/internal/cmd/gmail_attachment_text.go b/internal/cmd/gmail_attachment_text.go new file mode 100644 index 000000000..341c04ffb --- /dev/null +++ b/internal/cmd/gmail_attachment_text.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/ledongthuc/pdf" + + "github.com/steipete/gogcli/internal/gmailcontent" +) + +// addTextContent embeds extracted text for supported attachment types (PDF, +// HTML, plain text), or a reason when extraction is not possible. +func addTextContent(payload map[string]any, path string, size int64, filename, mimeType string) { + if size > maxInlineAttachmentBytes { + payload["reason"] = fmt.Sprintf("attachment size %d bytes exceeds inline size limit (%d bytes); content written to path only", size, maxInlineAttachmentBytes) + return + } + data, err := os.ReadFile(path) // #nosec G304 -- path is the attachment destination this command just wrote. + if err != nil { + payload["reason"] = fmt.Sprintf("failed to read downloaded attachment for text extraction: %v", err) + return + } + + // Attachment metadata can be missing (Gmail attachment IDs are unstable + // across API responses); fall back to sniffing the content. + mimeType = normalizeMimeType(mimeType) + if mimeType == "" { + mimeType = normalizeMimeType(http.DetectContentType(data)) + } + + switch { + case isPDFAttachment(data, filename, mimeType): + text, err := extractPDFText(data) + if err != nil { + payload["reason"] = fmt.Sprintf("pdf text extraction failed: %v; use --inline or --out for the raw bytes", err) + return + } + payload["text"] = text + if text == "" { + payload["note"] = "no extractable text; the PDF may be scanned or image-only" + } + case isHTMLAttachment(data, mimeType): + payload["text"] = gmailcontent.StripHTMLTags(string(data)) + case isTextAttachment(mimeType): + payload["text"] = string(data) + default: + payload["reason"] = fmt.Sprintf("text extraction not supported for mimeType %q; use --inline for base64 content or --out for a local copy", mimeType) + } +} + +// normalizeMimeType lowercases and strips parameters ("; charset=..."). +func normalizeMimeType(mimeType string) string { + base, _, _ := strings.Cut(mimeType, ";") + return strings.ToLower(strings.TrimSpace(base)) +} + +func isPDFAttachment(data []byte, filename, mimeType string) bool { + return strings.EqualFold(mimeType, "application/pdf") || + strings.EqualFold(filepath.Ext(filename), ".pdf") || + bytes.HasPrefix(data, []byte("%PDF-")) +} + +func isHTMLAttachment(data []byte, mimeType string) bool { + if strings.EqualFold(mimeType, "text/html") || strings.EqualFold(mimeType, "application/xhtml+xml") { + return true + } + return strings.HasPrefix(strings.ToLower(mimeType), "text/") && gmailcontent.LooksLikeHTML(string(data)) +} + +func isTextAttachment(mimeType string) bool { + lower := strings.ToLower(mimeType) + switch { + case strings.HasPrefix(lower, "text/"): + return true + case lower == "application/json", lower == "application/xml", + lower == "application/x-yaml", lower == "application/yaml": + return true + } + return false +} + +// extractPDFText pulls the plain text out of a PDF. The pdf library panics on +// some malformed inputs, so recover and surface those as errors. +func extractPDFText(data []byte) (text string, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%v", r) + } + }() + + reader, err := pdf.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return "", err + } + plain, err := reader.GetPlainText() + if err != nil { + return "", err + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, plain); err != nil { + return "", err + } + return strings.TrimSpace(buf.String()), nil +}