diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a030ca3..33325bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 5c441a52..22a45ad9 100644 --- a/README.md +++ b/README.md @@ -805,7 +805,8 @@ gog gmail batch modify --add STARRED --remove INBOX gog gmail filters list gog gmail filters create --from 'noreply@example.com' --add-label 'Notifications' gog gmail filters delete -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 diff --git a/docs/commands.generated.md b/docs/commands.generated.md index b3576b93..6c6b205f 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -318,7 +318,7 @@ Generated from `gog schema --json`. - [`gog gmail (mail,email) settings filters `](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) `](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) `](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 `](commands/gog-gmail-settings-forwarding.md) - Forwarding addresses diff --git a/docs/commands/README.md b/docs/commands/README.md index 08e43ea3..74b65c18 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -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 diff --git a/docs/commands/gog-gmail-settings-filters-export.md b/docs/commands/gog-gmail-settings-filters-export.md index 091e8912..ecce0a9b 100644 --- a/docs/commands/gog-gmail-settings-filters-export.md +++ b/docs/commands/gog-gmail-settings-filters-export.md @@ -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 @@ -26,11 +26,12 @@ gog gmail (mail,email) settings filters export [flags] | `-n`
`--dry-run`
`--dryrun`
`--noop`
`--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`
`--force`
`--assume-yes`
`--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`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | -| `-o`
`--out` | `string` | | Write JSON export to this file (defaults to stdout) | +| `-o`
`--out` | `string` | | Write export to this file (defaults to stdout) | | `-p`
`--plain`
`--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`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-gmail-settings-filters.md b/docs/commands/gog-gmail-settings-filters.md index af1a4a63..fcdd3942 100644 --- a/docs/commands/gog-gmail-settings-filters.md +++ b/docs/commands/gog-gmail-settings-filters.md @@ -18,7 +18,7 @@ gog gmail (mail,email) settings filters - [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 diff --git a/internal/cmd/gmail_filters.go b/internal/cmd/gmail_filters.go index 9273806a..14ca4aa0 100644 --- a/internal/cmd/gmail_filters.go +++ b/internal/cmd/gmail_filters.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "strings" @@ -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{} @@ -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 } @@ -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') + 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) @@ -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 } @@ -174,6 +212,7 @@ func (c *GmailFiltersExportCmd) Run(ctx context.Context, flags *RootFlags) error "exported": true, "path": outPath, "count": len(resp.Filter), + "format": format, }) } diff --git a/internal/cmd/gmail_filters_cmd_test.go b/internal/cmd/gmail_filters_cmd_test.go index 5e1fc4db..18e11c22 100644 --- a/internal/cmd/gmail_filters_cmd_test.go +++ b/internal/cmd/gmail_filters_cmd_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "encoding/xml" "io" "net/http" "net/http/httptest" @@ -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() @@ -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 & Alerts"`) { + t.Fatalf("missing escaped label name: %q", out) + } + for _, want := range []string{ + `name="from" value="a@example.com"`, + `name="subject" value="A&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) @@ -259,8 +339,27 @@ 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) } @@ -268,14 +367,24 @@ func TestGmailFiltersExport(t *testing.T) { if err != nil { t.Fatalf("read export: %v", err) } + if !strings.Contains(string(b), " 0 { + props = appendXMLProperty(props, "size", strconv.FormatInt(criteria.Size, 10)) + props = appendXMLProperty(props, "sizeUnit", "s_sb") + switch strings.ToLower(strings.TrimSpace(criteria.SizeComparison)) { + case "larger": + props = appendXMLProperty(props, "sizeOperator", "s_sl") + case "smaller": + props = appendXMLProperty(props, "sizeOperator", "s_ss") + } + } + return props +} + +func gmailFilterActionXMLProperties(action *gmail.FilterAction, labelNames map[string]string) []gmailFiltersXMLProperty { + if action == nil { + return nil + } + + var props []gmailFiltersXMLProperty + for _, id := range action.AddLabelIds { + switch strings.ToUpper(strings.TrimSpace(id)) { + case "": + continue + case gmailSystemLabelStarred: + props = appendXMLProperty(props, "shouldStar", "true") + case gmailSystemLabelImportant: + props = appendXMLProperty(props, "shouldAlwaysMarkAsImportant", "true") + case gmailSystemLabelTrash: + props = appendXMLProperty(props, "shouldTrash", "true") + default: + if smartLabel := gmailFilterSmartLabelXMLValue(id); smartLabel != "" { + props = appendXMLProperty(props, "smartLabelToApply", smartLabel) + continue + } + props = appendXMLProperty(props, "label", gmailFilterXMLLabelName(id, labelNames)) + } + } + for _, id := range action.RemoveLabelIds { + switch strings.ToUpper(strings.TrimSpace(id)) { + case "": + continue + case gmailSystemLabelInbox: + props = appendXMLProperty(props, "shouldArchive", "true") + case gmailSystemLabelUnread: + props = appendXMLProperty(props, "shouldMarkAsRead", "true") + case gmailSystemLabelSpam: + props = appendXMLProperty(props, "shouldNeverSpam", "true") + case gmailSystemLabelImportant: + props = appendXMLProperty(props, "shouldNeverMarkAsImportant", "true") + } + } + props = appendXMLProperty(props, "forwardTo", action.Forward) + return props +} + +func gmailFilterXMLLabelName(id string, labelNames map[string]string) string { + trimmed := strings.TrimSpace(id) + if labelNames == nil { + return trimmed + } + if name := strings.TrimSpace(labelNames[trimmed]); name != "" { + return name + } + return trimmed +} + +func gmailFilterSmartLabelXMLValue(id string) string { + switch strings.ToUpper(strings.TrimSpace(id)) { + case "CATEGORY_PERSONAL": + return "^smartlabel_personal" + case "CATEGORY_SOCIAL": + return "^smartlabel_social" + case "CATEGORY_PROMOTIONS": + return "^smartlabel_promo" + case "CATEGORY_UPDATES": + return "^smartlabel_notification" + case "CATEGORY_FORUMS": + return "^smartlabel_group" + default: + return "" + } +} + +func appendXMLProperty(props []gmailFiltersXMLProperty, name, value string) []gmailFiltersXMLProperty { + value = strings.TrimSpace(value) + if value == "" { + return props + } + return append(props, gmailFiltersXMLProperty{Name: name, Value: value}) +}