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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- Sheets: add header-safe `sheets table clear` for clearing table data rows without touching headers or footers.
- Sheets: add `sheets conditional-format` and `sheets banding` commands for rule-based formatting and alternating color banded ranges. (#378) — thanks @codBang.
- Agent docs: add a bundled `gog` skill for safe JSON-first Google Workspace automation from coding agents. (#353, #451) — thanks @TimPietrusky and @sluramod.
- Gmail: export filters as Gmail WebUI-importable Atom XML, while keeping API JSON export via `--format json`. (#174) — thanks @gwpl.

### Fixed
- Agent safety: compile baked safety profile policies into generated hash switches so raw allow/deny rule strings are not embedded as patchable YAML. (#540) — thanks @drewburchfield.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,8 @@ gog gmail batch modify <messageId> <messageId> --add STARRED --remove INBOX
gog gmail filters list
gog gmail filters create --from 'noreply@example.com' --add-label 'Notifications'
gog gmail filters delete <filterId>
gog gmail filters export --out ./filters.json
gog gmail filters export --out ./mailFilters.xml # Gmail WebUI importable XML
gog gmail filters export --format json --out ./filters.json

# Settings
gog gmail autoforward get
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ Generated from `gog schema --json`.
- [`gog gmail (mail,email) settings filters <command>`](commands/gog-gmail-settings-filters.md) - Filter operations
- [`gog gmail (mail,email) settings filters create (add,new) [flags]`](commands/gog-gmail-settings-filters-create.md) - Create a new email filter
- [`gog gmail (mail,email) settings filters delete (rm,del,remove) <filterId>`](commands/gog-gmail-settings-filters-delete.md) - Delete a filter
- [`gog gmail (mail,email) settings filters export [flags]`](commands/gog-gmail-settings-filters-export.md) - Export filters as JSON
- [`gog gmail (mail,email) settings filters export [flags]`](commands/gog-gmail-settings-filters-export.md) - Export filters as Gmail WebUI-compatible XML
- [`gog gmail (mail,email) settings filters get (info,show) <filterId>`](commands/gog-gmail-settings-filters-get.md) - Get a specific filter
- [`gog gmail (mail,email) settings filters list (ls)`](commands/gog-gmail-settings-filters-list.md) - List all email filters
- [`gog gmail (mail,email) settings forwarding <command>`](commands/gog-gmail-settings-forwarding.md) - Forwarding addresses
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ Generated pages: 466.
- [gog gmail settings filters](gog-gmail-settings-filters.md) - Filter operations
- [gog gmail settings filters create](gog-gmail-settings-filters-create.md) - Create a new email filter
- [gog gmail settings filters delete](gog-gmail-settings-filters-delete.md) - Delete a filter
- [gog gmail settings filters export](gog-gmail-settings-filters-export.md) - Export filters as JSON
- [gog gmail settings filters export](gog-gmail-settings-filters-export.md) - Export filters as Gmail WebUI-compatible XML
- [gog gmail settings filters get](gog-gmail-settings-filters-get.md) - Get a specific filter
- [gog gmail settings filters list](gog-gmail-settings-filters-list.md) - List all email filters
- [gog gmail settings forwarding](gog-gmail-settings-forwarding.md) - Forwarding addresses
Expand Down
5 changes: 3 additions & 2 deletions docs/commands/gog-gmail-settings-filters-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.

Export filters as JSON
Export filters as Gmail WebUI-compatible XML

## Usage

Expand All @@ -26,11 +26,12 @@ gog gmail (mail,email) settings filters export [flags]
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) |
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
| `--format` | `string` | | Export format: xml or json (default: xml; --json without --out uses json for compatibility) |
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `-o`<br>`--out` | `string` | | Write JSON export to this file (defaults to stdout) |
| `-o`<br>`--out` | `string` | | Write export to this file (defaults to stdout) |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/gog-gmail-settings-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ gog gmail (mail,email) settings filters <command>

- [gog gmail settings filters create](gog-gmail-settings-filters-create.md) - Create a new email filter
- [gog gmail settings filters delete](gog-gmail-settings-filters-delete.md) - Delete a filter
- [gog gmail settings filters export](gog-gmail-settings-filters-export.md) - Export filters as JSON
- [gog gmail settings filters export](gog-gmail-settings-filters-export.md) - Export filters as Gmail WebUI-compatible XML
- [gog gmail settings filters get](gog-gmail-settings-filters-get.md) - Get a specific filter
- [gog gmail settings filters list](gog-gmail-settings-filters-list.md) - List all email filters

Expand Down
51 changes: 45 additions & 6 deletions internal/cmd/gmail_filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
Expand All @@ -15,7 +16,7 @@ type GmailFiltersCmd struct {
Get GmailFiltersGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a specific filter"`
Create GmailFiltersCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new email filter"`
Delete GmailFiltersDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a filter"`
Export GmailFiltersExportCmd `cmd:"" name:"export" help:"Export filters as JSON"`
Export GmailFiltersExportCmd `cmd:"" name:"export" help:"Export filters as Gmail WebUI-compatible XML"`
}

type GmailFiltersListCmd struct{}
Expand Down Expand Up @@ -139,11 +140,12 @@ func (c *GmailFiltersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error
}

type GmailFiltersExportCmd struct {
Out string `name:"out" short:"o" help:"Write JSON export to this file (defaults to stdout)"`
Out string `name:"out" short:"o" help:"Write export to this file (defaults to stdout)"`
Format string `name:"format" help:"Export format: xml or json (default: xml; --json without --out uses json for compatibility)"`
}

func (c *GmailFiltersExportCmd) Run(ctx context.Context, flags *RootFlags) error {
svc, err := loadGmailSettingsService(ctx, flags)
account, svc, err := requireGmailService(ctx, flags)
if err != nil {
return err
}
Expand All @@ -153,10 +155,46 @@ func (c *GmailFiltersExportCmd) Run(ctx context.Context, flags *RootFlags) error
return err
}

payload := map[string]any{"filters": resp.Filter}
format := strings.ToLower(strings.TrimSpace(c.Format))
outPath := strings.TrimSpace(c.Out)
if format == "" {
format = "xml"
if outPath == "" && outfmt.IsJSON(ctx) {
format = "json"
}
}

payload := map[string]any{"filters": resp.Filter}
var data []byte
switch format {
case "json":
if outPath == "" {
return outfmt.WriteJSON(ctx, os.Stdout, payload)
}
data, err = json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
Comment on lines +174 to +178
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve JSON transforms for file exports

When --format json --out ... is used, this branch marshals with json.MarshalIndent instead of outfmt.WriteJSON, so JSON-mode transforms (--results-only / --select) are no longer applied to the exported file. Before this commit, gog gmail filters export --out ... always wrote through outfmt.WriteJSON, so scripts relying on projected/unwrapped output now get a different file payload even though the same global JSON flags are set.

Useful? React with 👍 / 👎.

case "xml":
labelNames, labelErr := fetchLabelIDToName(svc)
if labelErr != nil {
return labelErr
}
data, err = marshalGmailFiltersXML(account, resp.Filter, labelNames)
if err != nil {
return err
}
if outPath == "" {
_, err = os.Stdout.Write(data)
return err
}
default:
return usage("--format must be xml or json")
}

if outPath == "" {
return outfmt.WriteJSON(ctx, os.Stdout, payload)
return nil
}

f, outPath, err := createUserOutputFile(outPath)
Expand All @@ -165,7 +203,7 @@ func (c *GmailFiltersExportCmd) Run(ctx context.Context, flags *RootFlags) error
}
defer func() { _ = f.Close() }()

if err := outfmt.WriteJSON(ctx, f, payload); err != nil {
if _, err := f.Write(data); err != nil {
return err
}

Expand All @@ -174,6 +212,7 @@ func (c *GmailFiltersExportCmd) Run(ctx context.Context, flags *RootFlags) error
"exported": true,
"path": outPath,
"count": len(resp.Filter),
"format": format,
})
}

Expand Down
131 changes: 120 additions & 11 deletions internal/cmd/gmail_filters_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -210,19 +211,52 @@ func TestGmailFiltersList_NoFilters(t *testing.T) {

func TestGmailFiltersExport(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
origNow := nowGmailFiltersExport
t.Cleanup(func() {
newGmailService = origNew
nowGmailFiltersExport = origNow
})
nowGmailFiltersExport = func() time.Time { return time.Date(2026, 5, 5, 1, 2, 3, 0, time.UTC) }

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/gmail/v1/users/me/settings/filters") && r.Method == http.MethodGet {
switch {
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/labels") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"labels": []map[string]any{
{"id": "Label_1", "name": "Notifications & Alerts"},
},
})
return
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/settings/filters") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"filter": []map[string]any{
{"id": "f1", "criteria": map[string]any{"from": "a@example.com"}},
{
"id": "f1",
"criteria": map[string]any{
"from": "a@example.com",
"to": "b@example.com",
"subject": "A&B",
"query": `from:alerts has:attachment`,
"negatedQuery": "category:promotions",
"hasAttachment": true,
"excludeChats": true,
"size": 1024,
"sizeComparison": "larger",
},
"action": map[string]any{
"addLabelIds": []string{"Label_1", "STARRED", "IMPORTANT", "CATEGORY_SOCIAL"},
"removeLabelIds": []string{"INBOX", "UNREAD", "SPAM"},
"forward": "f@example.com",
},
},
},
})
return
default:
http.NotFound(w, r)
}
http.NotFound(w, r)
}))
defer srv.Close()

Expand All @@ -243,12 +277,58 @@ func TestGmailFiltersExport(t *testing.T) {
}
ctx := ui.WithUI(context.Background(), u)

t.Run("stdout json", func(t *testing.T) {
t.Run("stdout xml", func(t *testing.T) {
out := captureStdout(t, func() {
if err := runKong(t, &GmailFiltersExportCmd{}, []string{}, ctx, flags); err != nil {
t.Fatalf("export stdout: %v", err)
}
})
if !strings.HasPrefix(out, xml.Header) {
t.Fatalf("missing XML header: %q", out)
}
if !strings.Contains(out, `xmlns:apps="http://schemas.google.com/apps/2006"`) {
t.Fatalf("missing apps namespace: %q", out)
}
if !strings.Contains(out, `name="label" value="Notifications &amp; Alerts"`) {
t.Fatalf("missing escaped label name: %q", out)
}
for _, want := range []string{
`name="from" value="a@example.com"`,
`name="subject" value="A&amp;B"`,
`name="hasTheWord" value="from:alerts has:attachment"`,
`name="doesNotHaveTheWord" value="category:promotions"`,
`name="hasAttachment" value="true"`,
`name="excludeChats" value="true"`,
`name="size" value="1024"`,
`name="sizeUnit" value="s_sb"`,
`name="sizeOperator" value="s_sl"`,
`name="shouldStar" value="true"`,
`name="shouldAlwaysMarkAsImportant" value="true"`,
`name="smartLabelToApply" value="^smartlabel_social"`,
`name="shouldArchive" value="true"`,
`name="shouldMarkAsRead" value="true"`,
`name="shouldNeverSpam" value="true"`,
`name="forwardTo" value="f@example.com"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %s in XML:\n%s", want, out)
}
}
var parsed gmailFiltersXMLFeed
if err := xml.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("xml parse: %v", err)
}
if parsed.Author.Email != "a@b.com" || len(parsed.Entries) != 1 {
t.Fatalf("unexpected parsed feed: %#v", parsed)
}
})

t.Run("stdout json compatibility", func(t *testing.T) {
out := captureStdout(t, func() {
if err := runKong(t, &GmailFiltersExportCmd{}, []string{"--format", "json"}, ctx, flags); err != nil {
t.Fatalf("export stdout: %v", err)
}
})
var payload map[string]any
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("json parse: %v", err)
Expand All @@ -259,23 +339,52 @@ func TestGmailFiltersExport(t *testing.T) {
}
})

t.Run("file export", func(t *testing.T) {
path := t.TempDir() + "/filters.json"
t.Run("global json keeps old stdout json", func(t *testing.T) {
jsonCtx := outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
jsonFlags := *flags
jsonFlags.JSON = true
out := captureStdout(t, func() {
if err := runKong(t, &GmailFiltersExportCmd{}, []string{}, jsonCtx, &jsonFlags); err != nil {
t.Fatalf("export stdout: %v", err)
}
})
var payload map[string]any
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("json parse: %v", err)
}
filters, ok := payload["filters"].([]any)
if !ok || len(filters) != 1 {
t.Fatalf("unexpected payload: %#v", payload)
}
})

t.Run("file xml export", func(t *testing.T) {
path := t.TempDir() + "/mailFilters.xml"
if err := runKong(t, &GmailFiltersExportCmd{}, []string{"--out", path}, ctx, flags); err != nil {
t.Fatalf("export file: %v", err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read export: %v", err)
}
if !strings.Contains(string(b), "<feed") || !strings.Contains(string(b), "Mail Filters") {
t.Fatalf("unexpected XML export: %s", b)
}
})

t.Run("file json export", func(t *testing.T) {
path := t.TempDir() + "/filters.json"
if err := runKong(t, &GmailFiltersExportCmd{}, []string{"--format", "json", "--out", path}, ctx, flags); err != nil {
t.Fatalf("export file: %v", err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read export: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(b, &payload); err != nil {
t.Fatalf("json parse: %v", err)
}
filters, ok := payload["filters"].([]any)
if !ok || len(filters) != 1 {
t.Fatalf("unexpected payload: %#v", payload)
}
})
}

Expand Down
Loading
Loading