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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,8 +569,10 @@ gog gmail search 'newer_than:7d' --max 10
gog gmail thread get <threadId>
gog gmail thread get <threadId> --download # Download attachments to current dir
gog gmail thread get <threadId> --download --out-dir ./attachments
gog gmail thread get <threadId> --safe # Safe mode (see below)
gog gmail get <messageId>
gog gmail get <messageId> --format metadata
gog gmail get <messageId> --safe # Safe mode (see below)
gog gmail attachment <messageId> <attachmentId>
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
gog gmail url <threadId> # Print Gmail web URL
Expand Down Expand Up @@ -631,6 +633,12 @@ gog gmail watch serve --bind 127.0.0.1 --token <shared> --exclude-labels SPAM,TR
gog gmail history --since <historyId>
```

Safe mode (`--safe`):
- Strips all HTML using a full parser (not regex), removing scripts, styles, and tags
- Replaces all URLs with `[url removed]` to prevent phishing and tracking
- Decodes HTML entities to catch obfuscated URLs
- In JSON mode, provides a sanitized `bodies` map and clears raw body data from the payload

Gmail watch (Pub/Sub push):
- Create Pub/Sub topic + push subscription (OIDC preferred; shared token ok for dev).
- Full flow + payload details: `docs/watch.md`.
Expand Down
48 changes: 38 additions & 10 deletions internal/cmd/gmail_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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)"`
Safe bool `name:"safe" help:"Sanitize output: strip HTML, remove URLs, decode entities"`
}

const (
Expand Down Expand Up @@ -79,18 +80,31 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error {
"subject": headerValue(msg.Payload, "Subject"),
"date": headerValue(msg.Payload, "Date"),
}
if c.Safe {
for k, v := range headers {
headers[k] = sanitizeText(v)
}
}
payload := map[string]any{
"message": msg,
"headers": headers,
}
if unsubscribe != "" {
if unsubscribe != "" && !c.Safe {
payload["unsubscribe"] = unsubscribe
}
if format == gmailFormatFull {
if body := bestBodyText(msg.Payload); body != "" {
if c.Safe {
safeBody, isHTML := bestBodyForDisplay(msg.Payload)
if safeBody != "" {
payload["body"] = sanitizeBodyText(safeBody, isHTML)
}
} else if body := bestBodyText(msg.Payload); body != "" {
payload["body"] = body
}
}
if c.Safe {
clearPayloadBodies(msg.Payload)
}
if format == gmailFormatFull || format == gmailFormatMetadata {
attachments := collectAttachments(msg.Payload)
if len(attachments) > 0 {
Expand Down Expand Up @@ -118,11 +132,17 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u.Out().Println(string(decoded))
return nil
case gmailFormatMetadata, gmailFormatFull:
u.Out().Printf("from\t%s", headerValue(msg.Payload, "From"))
u.Out().Printf("to\t%s", headerValue(msg.Payload, "To"))
u.Out().Printf("subject\t%s", headerValue(msg.Payload, "Subject"))
if c.Safe {
u.Out().Printf("from\t%s", sanitizeText(headerValue(msg.Payload, "From")))
u.Out().Printf("to\t%s", sanitizeText(headerValue(msg.Payload, "To")))
u.Out().Printf("subject\t%s", sanitizeText(headerValue(msg.Payload, "Subject")))
} else {
u.Out().Printf("from\t%s", headerValue(msg.Payload, "From"))
u.Out().Printf("to\t%s", headerValue(msg.Payload, "To"))
u.Out().Printf("subject\t%s", headerValue(msg.Payload, "Subject"))
}
u.Out().Printf("date\t%s", headerValue(msg.Payload, "Date"))
if unsubscribe != "" {
if unsubscribe != "" && !c.Safe {
u.Out().Printf("unsubscribe\t%s", unsubscribe)
}
attachments := attachmentOutputs(collectAttachments(msg.Payload))
Expand All @@ -131,10 +151,18 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error {
printAttachmentLines(u.Out(), attachments)
}
if format == gmailFormatFull {
body := bestBodyText(msg.Payload)
if body != "" {
u.Out().Println("")
u.Out().Println(body)
if c.Safe {
body, isHTML := bestBodyForDisplay(msg.Payload)
if body != "" {
u.Out().Println("")
u.Out().Println(sanitizeBodyText(body, isHTML))
}
} else {
body := bestBodyText(msg.Payload)
if body != "" {
u.Out().Println("")
u.Out().Println(body)
}
}
}
return nil
Expand Down
156 changes: 156 additions & 0 deletions internal/cmd/gmail_get_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,159 @@ func TestGmailGetCmd_RawEmpty(t *testing.T) {
t.Fatalf("unexpected stderr: %q", errOut)
}
}

func TestGmailGetCmd_Safe_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })

htmlBody := base64.RawURLEncoding.EncodeToString([]byte(
`<html><body><script>track()</script><p>Hello https://phish.com/steal</p></body></html>`,
))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m1",
"threadId": "t1",
"labelIds": []string{"INBOX"},
"payload": map[string]any{
"mimeType": "text/html",
"body": map[string]any{"data": htmlBody},
"headers": []map[string]any{
{"name": "From", "value": "a@example.com"},
{"name": "To", "value": "b@example.com"},
{"name": "Subject", "value": "Visit https://evil.com now"},
{"name": "Date", "value": "Fri, 26 Dec 2025 10:00:00 +0000"},
{"name": "List-Unsubscribe", "value": "<https://unsub.example.com>"},
},
},
})
}))
defer srv.Close()

svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }

flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})

cmd := &GmailGetCmd{Safe: true}
if err := runKong(t, cmd, []string{"m1", "--format", "full", "--safe"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
}
})
})

var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v", err)
}

// Body should be sanitized
body, _ := parsed["body"].(string)
if strings.Contains(body, "https://") {
t.Fatalf("--safe body should not contain URLs, got: %q", body)
}
if !strings.Contains(body, "Hello") {
t.Fatalf("--safe body should contain 'Hello', got: %q", body)
}

// Unsubscribe should not be present
if _, ok := parsed["unsubscribe"]; ok {
t.Fatalf("--safe JSON should not include unsubscribe link")
}

// Headers should be sanitized
headers, _ := parsed["headers"].(map[string]any)
subject, _ := headers["subject"].(string)
if strings.Contains(subject, "https://") {
t.Fatalf("--safe subject should not contain URLs, got: %q", subject)
}
if !strings.Contains(subject, "[url removed]") {
t.Fatalf("--safe subject should contain [url removed], got: %q", subject)
}
}

func TestGmailGetCmd_Safe_Text(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })

bodyData := base64.RawURLEncoding.EncodeToString([]byte("Hello visit https://phish.com/login for details"))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m1",
"threadId": "t1",
"labelIds": []string{"INBOX"},
"payload": map[string]any{
"mimeType": "text/plain",
"body": map[string]any{"data": bodyData},
"headers": []map[string]any{
{"name": "From", "value": "a@example.com"},
{"name": "To", "value": "b@example.com"},
{"name": "Subject", "value": "Urgent https://evil.com action"},
{"name": "Date", "value": "Fri, 26 Dec 2025 10:00:00 +0000"},
{"name": "List-Unsubscribe", "value": "<https://unsub.example.com>"},
},
},
})
}))
defer srv.Close()

svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }

flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)

cmd := &GmailGetCmd{Safe: true}
if err := runKong(t, cmd, []string{"m1", "--format", "full", "--safe"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
}
})
})

if strings.Contains(out, "https://") {
t.Fatalf("--safe text output should not contain URLs, got: %q", out)
}
if !strings.Contains(out, "[url removed]") {
t.Fatalf("--safe text output should contain [url removed], got: %q", out)
}
if strings.Contains(out, "unsubscribe") {
t.Fatalf("--safe text output should not show unsubscribe link, got: %q", out)
}
}
Loading