From fbb230b60bd4b9e73849752a2c8eb95b857996f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 03:26:31 +0000 Subject: [PATCH] feat(gmail): add filters export command (#119) (thanks @Jeswang) --- CHANGELOG.md | 1 + README.md | 1 + internal/cmd/gmail_filters.go | 50 ++++++++++++++++++ internal/cmd/gmail_filters_cmd_test.go | 72 ++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d24af8e..d3274bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Auth: add `auth add|manage --listen-addr` plus `--redirect-host` for browser OAuth behind proxies or remote loopback forwarding. (#227) — thanks @cyberfox. - Auth: add `--extra-scopes` to `auth add` for appending custom OAuth scope URIs beyond the built-in service scopes. (#421) — thanks @peteradams2026. - Gmail: add `gmail labels rename` to rename user labels by ID or exact name, with system-label guards and wrong-case ID safety. (#391) — thanks @adam-zethraeus. +- Gmail: add `gmail filters export` to dump filter definitions as JSON to stdout or a file for backup/script workflows. (#119) — thanks @Jeswang. - Gmail: add `gmail messages modify` for single-message label changes, complementing thread- and batch-level modify flows. (#281) — thanks @zerone0x. - Calendar: add `calendar subscribe` (aliases `sub`, `add-calendar`) to add a shared calendar to the current account’s calendar list. (#327) — thanks @cdthompson. - Calendar: add `calendar alias list|set|unset`, and let calendar commands resolve configured aliases before API/name lookup. (#393) — thanks @salmonumbrella. diff --git a/README.md b/README.md index 85a70cf..ec0f700 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,7 @@ 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 # Settings gog gmail autoforward get diff --git a/internal/cmd/gmail_filters.go b/internal/cmd/gmail_filters.go index 62fd8ed..ae35e2e 100644 --- a/internal/cmd/gmail_filters.go +++ b/internal/cmd/gmail_filters.go @@ -19,6 +19,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"` } type GmailFiltersListCmd struct{} @@ -386,3 +387,52 @@ func (c *GmailFiltersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error u.Out().Printf("Filter %s deleted successfully", filterID) return nil } + +type GmailFiltersExportCmd struct { + Out string `name:"out" short:"o" help:"Write JSON export to this file (defaults to stdout)"` +} + +func (c *GmailFiltersExportCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Users.Settings.Filters.List("me").Do() + if err != nil { + return err + } + + payload := map[string]any{"filters": resp.Filter} + outPath := strings.TrimSpace(c.Out) + if outPath == "" { + return outfmt.WriteJSON(ctx, os.Stdout, payload) + } + + f, err := os.Create(outPath) //nolint:gosec // explicit user-selected output path + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + if err := outfmt.WriteJSON(ctx, f, payload); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "exported": true, + "path": outPath, + "count": len(resp.Filter), + }) + } + + u.Out().Printf("Exported %d filters to %s", len(resp.Filter), outPath) + return nil +} diff --git a/internal/cmd/gmail_filters_cmd_test.go b/internal/cmd/gmail_filters_cmd_test.go index 46349d7..e27ccde 100644 --- a/internal/cmd/gmail_filters_cmd_test.go +++ b/internal/cmd/gmail_filters_cmd_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -203,3 +204,74 @@ func TestGmailFiltersList_NoFilters(t *testing.T) { } }) } + +func TestGmailFiltersExport(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + 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 { + 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"}}, + }, + }) + return + } + http.NotFound(w, r) + })) + 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"} + 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) + + t.Run("stdout json", func(t *testing.T) { + out := captureStdout(t, func() { + if err := runKong(t, &GmailFiltersExportCmd{}, []string{}, 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) + } + filters, ok := payload["filters"].([]any) + if !ok || len(filters) != 1 { + t.Fatalf("unexpected payload: %#v", payload) + } + }) + + t.Run("file export", func(t *testing.T) { + path := t.TempDir() + "/filters.json" + 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) + } + 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) + } + }) +}