feat(gmail): export filters as WebUI XML (#553)

* feat(gmail): export filters as WebUI XML

* fix(gmail): satisfy filter export lint
This commit is contained in:
Peter Steinberger 2026-05-05 04:02:53 +01:00 committed by GitHub
parent 3ed52354a0
commit e8e1ac4635
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 381 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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{
"filter": []map[string]any{
{"id": "f1", "criteria": map[string]any{"from": "a@example.com"}},
"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",
"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 &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)
@ -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), "<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)
}
})
}

View File

@ -0,0 +1,206 @@
package cmd
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
"time"
"google.golang.org/api/gmail/v1"
)
const (
gmailFiltersAtomNamespace = "http://www.w3.org/2005/Atom"
gmailFiltersAppsNamespace = "http://schemas.google.com/apps/2006"
)
var nowGmailFiltersExport = time.Now
type gmailFiltersXMLFeed struct {
XMLName xml.Name `xml:"feed"`
XMLNS string `xml:"xmlns,attr"`
XMLNSApps string `xml:"xmlns:apps,attr"`
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Author gmailFiltersXMLAuthor `xml:"author"`
Entries []gmailFiltersXMLEntry `xml:"entry"`
}
type gmailFiltersXMLAuthor struct {
Name string `xml:"name"`
Email string `xml:"email"`
}
type gmailFiltersXMLEntry struct {
Category gmailFiltersXMLCategory `xml:"category"`
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Content string `xml:"content"`
Properties []gmailFiltersXMLProperty `xml:"apps:property"`
}
type gmailFiltersXMLCategory struct {
Term string `xml:"term,attr"`
}
type gmailFiltersXMLProperty struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
func marshalGmailFiltersXML(account string, filters []*gmail.Filter, labelNames map[string]string) ([]byte, error) {
nowTime := nowGmailFiltersExport().UTC()
now := nowTime.Format(time.RFC3339)
feed := gmailFiltersXMLFeed{
XMLNS: gmailFiltersAtomNamespace,
XMLNSApps: gmailFiltersAppsNamespace,
Title: "Mail Filters",
ID: fmt.Sprintf("tag:mail.google.com,2008:filters:%d", nowTime.UnixMilli()),
Updated: now,
Author: gmailFiltersXMLAuthor{
Name: strings.TrimSpace(account),
Email: strings.TrimSpace(account),
},
Entries: make([]gmailFiltersXMLEntry, 0, len(filters)),
}
for _, filter := range filters {
if filter == nil {
continue
}
entry := gmailFiltersXMLEntry{
Category: gmailFiltersXMLCategory{Term: "filter"},
Title: "Mail Filter",
ID: "tag:mail.google.com,2008:filter:" + strings.TrimSpace(filter.Id),
Updated: now,
}
entry.Properties = append(entry.Properties, gmailFilterCriteriaXMLProperties(filter.Criteria)...)
entry.Properties = append(entry.Properties, gmailFilterActionXMLProperties(filter.Action, labelNames)...)
feed.Entries = append(feed.Entries, entry)
}
var buf bytes.Buffer
buf.WriteString(xml.Header)
enc := xml.NewEncoder(&buf)
enc.Indent("", " ")
if err := enc.Encode(feed); err != nil {
return nil, err
}
if err := enc.Flush(); err != nil {
return nil, err
}
buf.WriteByte('\n')
return buf.Bytes(), nil
}
func gmailFilterCriteriaXMLProperties(criteria *gmail.FilterCriteria) []gmailFiltersXMLProperty {
if criteria == nil {
return nil
}
var props []gmailFiltersXMLProperty
props = appendXMLProperty(props, "from", criteria.From)
props = appendXMLProperty(props, "to", criteria.To)
props = appendXMLProperty(props, "subject", criteria.Subject)
props = appendXMLProperty(props, "hasTheWord", criteria.Query)
props = appendXMLProperty(props, "doesNotHaveTheWord", criteria.NegatedQuery)
if criteria.HasAttachment {
props = appendXMLProperty(props, "hasAttachment", "true")
}
if criteria.ExcludeChats {
props = appendXMLProperty(props, "excludeChats", "true")
}
if criteria.Size > 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})
}