Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs/commands/gog-gmail-attachment.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ gog gmail (mail,email) attachment <messageId> <attachmentId> [flags]
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
| `--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`<br>`--json`<br>`--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`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
Expand All @@ -38,6 +39,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. |
| `--text` | `bool` | | Also return the attachment content as extracted text (PDF, HTML, plain text) in the response |
| `-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
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ after the bounded retry window, the command exits with retryable code `8`.
- `gog gmail thread get <threadId> [--download]`
- `gog gmail thread modify <threadId> [--add ...] [--remove ...]`
- `gog gmail get <messageId> [--format full|metadata|raw] [--headers ...]`
- `gog gmail attachment <messageId> <attachmentId> [--out PATH] [--name NAME]`
- `gog gmail attachment <messageId> <attachmentId> [--out PATH] [--name NAME] [--inline] [--text]`
- `gog gmail url <threadIds...>`
- `gog gmail reply <messageId> [--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 <file>...]`
- `gog gmail reply-all <messageId> [--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 <file>...]`
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
163 changes: 163 additions & 0 deletions internal/cmd/execute_gmail_attachment_inline_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading