feat(gmail): add filters export command (#119) (thanks @Jeswang)

This commit is contained in:
Peter Steinberger 2026-03-09 03:26:31 +00:00
parent 004b68cc70
commit fbb230b60b
4 changed files with 124 additions and 0 deletions

View File

@ -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 accounts 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.

View File

@ -656,6 +656,7 @@ 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
# Settings
gog gmail autoforward get

View File

@ -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
}

View File

@ -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)
}
})
}