Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address>` (`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
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions internal/mailfilter/mailfilter.go
Original file line number Diff line number Diff line change
@@ -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
}
127 changes: 127 additions & 0 deletions internal/mailfilter/mailfilter_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading