From cf6d38252bd67fdeacef01e4062f95b1a7a4ffbf Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Fri, 8 May 2026 21:38:32 +0200 Subject: [PATCH] feat: mail standard filters get_mailstandardfilter read module + CLI Add `internal/mailfilter` with typed `StandardFilter` + `StandardFilterList`, `Client.List`, and the `kasapi-cli mail filters list` subcommand wrapping `get_mailstandardfilter`. The endpoint takes no parameters and returns the catalog of preset spam/virus filters referenced by `mail_spamfilter` on accounts and forwards. Mapping test runs against the shipped `testdata/mailfilter/` fixture. Refs #9. --- CHANGELOG.md | 8 ++ ROADMAP.md | 3 +- internal/cli/mail.go | 33 +++++++ internal/mailfilter/mailfilter.go | 99 +++++++++++++++++++ internal/mailfilter/mailfilter_test.go | 127 +++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 internal/mailfilter/mailfilter.go create mode 100644 internal/mailfilter/mailfilter_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed9c4a..9b97606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/mailfilter` read module and `kasapi-cli mail filters list` + subcommand wrapping `get_mailstandardfilter`. Decodes the Array of + `{filter, type, title, recommended}` Maps into a typed + `StandardFilterList` so callers can resolve the preset filter ids + used by `mail_spamfilter` on accounts/forwards. Mapping test runs + against `testdata/mailfilter/get_mailstandardfilter_response_success.xml`. + Refs #9. + - `internal/mailforward` read module and `kasapi-cli mail forwards list|get` subcommand tree wrapping `get_mailforwards`. The list variant decodes the full Map-of-Maps payload into a typed diff --git a/ROADMAP.md b/ROADMAP.md index 1641ae7..332c792 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl - [ ] Mail account write paths (`add_mailaccount`, `update_mailaccount`, `delete_mailaccount`) - [x] `mail forwards list` / `mail forwards get
` (`get_mailforwards`, with `mail_forward` filter) - [ ] Mail forward write paths (`add_mailforward`, `update_mailforward`, `delete_mailforward`) -- [ ] Mail standard filters (`get_mailstandardfilter`, `update_mailstandardfilter`) +- [x] `mail filters list` (`get_mailstandardfilter`) +- [ ] Mail standard filter write paths (`update_mailstandardfilter`) - [ ] Mailing lists (`get_mailinglists`, `add_mailinglist`, `update_mailinglist`, `delete_mailinglist`) ## Hosting resources diff --git a/internal/cli/mail.go b/internal/cli/mail.go index 4f40743..7895679 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/chmmou/kasapi-cli/internal/mailaccount" + "github.com/chmmou/kasapi-cli/internal/mailfilter" "github.com/chmmou/kasapi-cli/internal/mailforward" ) @@ -18,10 +19,42 @@ func NewMailCmd(opts *RootOptions) *cobra.Command { cmd.AddCommand( newMailAccountsCmd(opts), newMailForwardsCmd(opts), + newMailFiltersCmd(opts), ) return cmd } +func newMailFiltersCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "filters", + Short: "Inspect mail standard filters (get_mailstandardfilter)", + } + cmd.AddCommand(newMailFiltersListCmd(opts)) + return cmd +} + +func newMailFiltersListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the available standard mail filters (get_mailstandardfilter)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := mailfilter.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_mailstandardfilter") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + func newMailAccountsCmd(opts *RootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "accounts", diff --git a/internal/mailfilter/mailfilter.go b/internal/mailfilter/mailfilter.go new file mode 100644 index 0000000..689c6bb --- /dev/null +++ b/internal/mailfilter/mailfilter.go @@ -0,0 +1,99 @@ +package mailfilter + +import ( + "context" + "fmt" + + "github.com/chmmou/kasapi-cli/internal/soap" +) + +// Caller is the subset of *api.Client this package depends on. The +// indirection keeps tests free of network setup. +type Caller interface { + Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error) +} + +// StandardFilter is one entry of get_mailstandardfilter, describing a +// preset spam/virus filter that can be referenced by `mail_spamfilter` +// on a mail account or forward. +type StandardFilter struct { + Filter string `json:"filter" yaml:"filter"` + Type string `json:"type" yaml:"type"` + Title string `json:"title" yaml:"title"` + Recommended string `json:"recommended" yaml:"recommended"` +} + +// StandardFilterList is the typed payload of get_mailstandardfilter; +// satisfies cli.Tabular. +type StandardFilterList []StandardFilter + +// Client groups the read endpoint scoped to mail standard filters. +type Client struct { + API Caller +} + +// NewClient returns a Client backed by the given Caller. +func NewClient(c Caller) *Client { return &Client{API: c} } + +// List calls get_mailstandardfilter and decodes the response into a +// StandardFilterList. The endpoint takes no parameters. +func (c *Client) List(ctx context.Context) (StandardFilterList, error) { + resp, err := c.API.Call(ctx, "get_mailstandardfilter", nil) + if err != nil { + return nil, err + } + list, err := DecodeStandardFilters(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("mailfilter: get_mailstandardfilter: %w", err) + } + return list, nil +} + +// DecodeStandardFilters maps the ReturnInfo of a get_mailstandardfilter +// response (an Array of Maps) into the typed StandardFilterList. +func DecodeStandardFilters(returnInfo soap.Value) (StandardFilterList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("mailfilter: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(StandardFilterList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("mailfilter: ReturnInfo[%d] is not a Map", i) + } + out = append(out, StandardFilter{ + Filter: getString(item, "filter"), + Type: getString(item, "type"), + Title: getString(item, "title"), + Recommended: getString(item, "recommended"), + }) + } + return out, nil +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +// TableHeaders returns the columns used by --output=table for +// StandardFilterList. +func (StandardFilterList) TableHeaders() []string { + return []string{"FILTER", "TYPE", "TITLE", "RECOMMENDED"} +} + +// TableRows emits one row per StandardFilter entry. +func (l StandardFilterList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, f := range l { + rows = append(rows, []string{ + f.Filter, + f.Type, + f.Title, + f.Recommended, + }) + } + return rows +} diff --git a/internal/mailfilter/mailfilter_test.go b/internal/mailfilter/mailfilter_test.go new file mode 100644 index 0000000..39d0ab0 --- /dev/null +++ b/internal/mailfilter/mailfilter_test.go @@ -0,0 +1,127 @@ +package mailfilter_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/mailfilter" + "github.com/chmmou/kasapi-cli/internal/soap" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("repo root not found from %q", file) + } + dir = parent + } +} + +func decodeFixture(t *testing.T, name string) *soap.Response { + t.Helper() + path := filepath.Join(repoRoot(t), "testdata", "mailfilter", name) + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", name, err) + } + defer func() { _ = f.Close() }() + resp, err := soap.Decode(f) + if err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return resp +} + +type fakeCaller struct { + resp *soap.Response + err error + + gotAction string + gotParams map[string]any +} + +func (f *fakeCaller) Call(_ context.Context, action string, params map[string]any) (*soap.Response, error) { + f.gotAction = action + f.gotParams = params + return f.resp, f.err +} + +func TestDecodeStandardFilters(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailstandardfilter_response_success.xml") + got, err := mailfilter.DecodeStandardFilters(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeStandardFilters: %v", err) + } + if len(got) != 9 { + t.Fatalf("len = %d, want 9 (per fixture arrayType)", len(got)) + } + if got[0].Filter != "rspamd" || got[0].Type != "rspamd" || got[0].Recommended != "Y" { + t.Errorf("got[0] = %+v", got[0]) + } + // Spot-check an entry whose type differs from the filter id. + for _, f := range got { + if f.Filter == "pdw" { + if f.Type != "reject" || f.Title != "policyd-weight" { + t.Errorf("pdw entry = %+v", f) + } + return + } + } + t.Errorf("pdw entry missing") +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailstandardfilter_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := mailfilter.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_mailstandardfilter" { + t.Errorf("action = %q, want get_mailstandardfilter", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 9 { + t.Errorf("len = %d, want 9", len(list)) + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := mailfilter.NewClient(&fakeCaller{err: want}) + if _, err := c.List(context.Background()); !errors.Is(err, want) { + t.Errorf("List err = %v, want %v wrapped", err, want) + } +} + +func TestStandardFilterListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailstandardfilter_response_success.xml") + list, _ := mailfilter.DecodeStandardFilters(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 9 { + t.Fatalf("rows = %d, want 9", len(rows)) + } + if rows[0][0] != "rspamd" { + t.Errorf("rows[0][0] = %q, want rspamd", rows[0][0]) + } +}