From 493aabebda5fe920fa191f7f8a52cd30ebd189a0 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Fri, 8 May 2026 21:34:08 +0200 Subject: [PATCH] feat: mail forwards get_mailforwards read module + CLI Add `internal/mailforward` with typed `MailForward` + `MailForwardList`, `Client.List`/`Client.Get`, and the `kasapi-cli mail forwards list|get` subcommand tree. The `get` variant reuses `get_mailforwards` with a `mail_forward` filter (the source address) and unwraps the single-entry result, mirroring the mail accounts pattern. Mapping tests run against the shipped `testdata/mailforward/` fixtures. Refs #9. --- CHANGELOG.md | 9 ++ ROADMAP.md | 3 +- internal/cli/mail.go | 62 +++++++- internal/mailforward/mailforward.go | 152 +++++++++++++++++++ internal/mailforward/mailforward_test.go | 181 +++++++++++++++++++++++ 5 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 internal/mailforward/mailforward.go create mode 100644 internal/mailforward/mailforward_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fea554..6ed9c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `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 + `MailForwardList`; `get
` reuses the same endpoint with a + `mail_forward` filter (the source address) and unwraps the + single-entry result, mirroring the mail accounts pattern. Mapping + tests run against `testdata/mailforward/get_mailforwards_response_success.xml` + and `get_mailforward_response_success.xml`. Refs #9. + - `internal/mailaccount` read module and `kasapi-cli mail accounts list|get` subcommand tree wrapping `get_mailaccounts`. The list variant decodes the full Map-of-Maps payload into a typed diff --git a/ROADMAP.md b/ROADMAP.md index 21fda61..1641ae7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl - [x] `mail accounts list` / `mail accounts get ` (`get_mailaccounts`, with `mail_login` filter) - [ ] Mail account write paths (`add_mailaccount`, `update_mailaccount`, `delete_mailaccount`) -- [ ] Mail forwards (`get_mailforwards`, `add_mailforward`, `update_mailforward`, `delete_mailforward`) +- [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`) - [ ] Mailing lists (`get_mailinglists`, `add_mailinglist`, `update_mailinglist`, `delete_mailinglist`) diff --git a/internal/cli/mail.go b/internal/cli/mail.go index e88ed47..4f40743 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/mailforward" ) // NewMailCmd returns the "kasapi-cli mail" subcommand tree, grouping @@ -14,7 +15,10 @@ func NewMailCmd(opts *RootOptions) *cobra.Command { Use: "mail", Short: "Inspect mail accounts, forwards, filters, and mailing lists", } - cmd.AddCommand(newMailAccountsCmd(opts)) + cmd.AddCommand( + newMailAccountsCmd(opts), + newMailForwardsCmd(opts), + ) return cmd } @@ -73,3 +77,59 @@ func newMailAccountsGetCmd(opts *RootOptions) *cobra.Command { }, } } + +func newMailForwardsCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "forwards", + Short: "Inspect mail forwards (get_mailforwards)", + } + cmd.AddCommand( + newMailForwardsListCmd(opts), + newMailForwardsGetCmd(opts), + ) + return cmd +} + +func newMailForwardsListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all mail forwards (get_mailforwards)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := mailforward.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_mailforwards") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + +func newMailForwardsGetCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "get
", + Short: "Show details for a single mail forward (get_mailforwards with mail_forward)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + f, err := mailforward.NewClient(api).Get(cmd.Context(), args[0]) + if err != nil { + return APIError(err, "get_mailforwards") + } + if err := Render(cmd.OutOrStdout(), opts.Output, f); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} diff --git a/internal/mailforward/mailforward.go b/internal/mailforward/mailforward.go new file mode 100644 index 0000000..ebfb3ee --- /dev/null +++ b/internal/mailforward/mailforward.go @@ -0,0 +1,152 @@ +package mailforward + +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) +} + +// MailForward is one entry of get_mailforwards. The list and singular +// views (the latter being get_mailforwards called with a mail_forward +// filter) return the same Map shape, so a single struct covers both. +type MailForward struct { + // The KAS API returns both the legacy mail_forward_adress key + // (single d) and the canonical mail_forward_address key with + // identical content. We keep both so a JSON/YAML round-trip + // preserves the raw payload, but consumers should prefer Address. + Adress string `json:"mail_forward_adress" yaml:"mail_forward_adress"` + Address string `json:"mail_forward_address" yaml:"mail_forward_address"` + + Comment string `json:"mail_forward_comment" yaml:"mail_forward_comment"` + Targets string `json:"mail_forward_targets" yaml:"mail_forward_targets"` + Spamfilter string `json:"mail_forward_spamfilter" yaml:"mail_forward_spamfilter"` + InProgress string `json:"in_progress" yaml:"in_progress"` +} + +// MailForwardList is the typed payload of get_mailforwards; satisfies +// cli.Tabular. +type MailForwardList []MailForward + +// Client groups the read endpoints scoped to mail forwards: +// get_mailforwards (list and singular). +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_mailforwards without parameters and decodes the +// response into a MailForwardList covering every mail forward visible +// to the login. +func (c *Client) List(ctx context.Context) (MailForwardList, error) { + resp, err := c.API.Call(ctx, "get_mailforwards", nil) + if err != nil { + return nil, err + } + list, err := DecodeMailForwards(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("mailforward: get_mailforwards: %w", err) + } + return list, nil +} + +// Get calls get_mailforwards with a mail_forward filter (the source +// address) and returns the single matching MailForward. The KAS API +// still wraps the result in an array; we unwrap it so callers do not +// have to. An empty array surfaces as a not-found error. +func (c *Client) Get(ctx context.Context, address string) (MailForward, error) { + if address == "" { + return MailForward{}, fmt.Errorf("mailforward: address is required") + } + resp, err := c.API.Call(ctx, "get_mailforwards", map[string]any{"mail_forward": address}) + if err != nil { + return MailForward{}, err + } + list, err := DecodeMailForwards(resp.Body.ReturnInfo) + if err != nil { + return MailForward{}, fmt.Errorf("mailforward: get_mailforwards: %w", err) + } + if len(list) == 0 { + return MailForward{}, fmt.Errorf("mailforward: %q not found", address) + } + return list[0], nil +} + +// DecodeMailForwards maps the ReturnInfo of a get_mailforwards response +// (an Array of Maps) into the typed MailForwardList. +func DecodeMailForwards(returnInfo soap.Value) (MailForwardList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("mailforward: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(MailForwardList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("mailforward: ReturnInfo[%d] is not a Map", i) + } + out = append(out, MailForward{ + Adress: getString(item, "mail_forward_adress"), + Address: getString(item, "mail_forward_address"), + Comment: getString(item, "mail_forward_comment"), + Targets: getString(item, "mail_forward_targets"), + Spamfilter: getString(item, "mail_forward_spamfilter"), + InProgress: getString(item, "in_progress"), + }) + } + 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 +// MailForwardList. +func (MailForwardList) TableHeaders() []string { + return []string{"ADDRESS", "TARGETS", "SPAMFILTER", "COMMENT", "IN_PROGRESS"} +} + +// TableRows emits one row per MailForward entry. +func (l MailForwardList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, f := range l { + rows = append(rows, []string{ + f.Address, + f.Targets, + f.Spamfilter, + f.Comment, + f.InProgress, + }) + } + return rows +} + +// TableHeaders for the singular MailForward view: a key/value layout +// to keep multi-target lists readable. +func (MailForward) TableHeaders() []string { + return []string{"FIELD", "VALUE"} +} + +// TableRows emits the scalar fields. The redundant mail_forward_adress +// legacy key is omitted from the table; it remains available via +// --output=json|yaml. +func (f MailForward) TableRows() [][]string { + return [][]string{ + {"mail_forward_address", f.Address}, + {"mail_forward_targets", f.Targets}, + {"mail_forward_spamfilter", f.Spamfilter}, + {"mail_forward_comment", f.Comment}, + {"in_progress", f.InProgress}, + } +} diff --git a/internal/mailforward/mailforward_test.go b/internal/mailforward/mailforward_test.go new file mode 100644 index 0000000..45e06d9 --- /dev/null +++ b/internal/mailforward/mailforward_test.go @@ -0,0 +1,181 @@ +package mailforward_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/mailforward" + "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", "mailforward", 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 TestDecodeMailForwards(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailforwards_response_success.xml") + got, err := mailforward.DecodeMailForwards(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeMailForwards: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (per fixture arrayType)", len(got)) + } + f := got[0] + if f.Address != "from@example.de" || f.Adress != "from@example.de" { + t.Errorf("address pair = %q / %q", f.Address, f.Adress) + } + if f.Targets != "to@example.de" { + t.Errorf("Targets = %q", f.Targets) + } + if f.Spamfilter != "kaspdw" { + t.Errorf("Spamfilter = %q", f.Spamfilter) + } + if f.InProgress != "FALSE" { + t.Errorf("InProgress = %q", f.InProgress) + } +} + +func TestDecodeMailForwardSingular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailforward_response_success.xml") + got, err := mailforward.DecodeMailForwards(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeMailForwards: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + if got[0].Address == "" { + t.Errorf("Address empty") + } +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailforwards_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := mailforward.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_mailforwards" { + t.Errorf("action = %q, want get_mailforwards", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 2 { + t.Errorf("len = %d, want 2", len(list)) + } +} + +func TestClientGet(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailforward_response_success.xml") + fc := &fakeCaller{resp: resp} + f, err := mailforward.NewClient(fc).Get(context.Background(), "from@example.de") + if err != nil { + t.Fatalf("Get: %v", err) + } + if fc.gotAction != "get_mailforwards" { + t.Errorf("action = %q, want get_mailforwards", fc.gotAction) + } + if got, _ := fc.gotParams["mail_forward"].(string); got != "from@example.de" { + t.Errorf("params[mail_forward] = %v, want from@example.de", fc.gotParams["mail_forward"]) + } + if f.Address == "" { + t.Errorf("Address empty") + } +} + +func TestClientGetEmptyAddress(t *testing.T) { + t.Parallel() + c := mailforward.NewClient(&fakeCaller{}) + if _, err := c.Get(context.Background(), ""); err == nil { + t.Errorf("Get(\"\") err = nil, want validation error") + } +} + +func TestClientGetNotFound(t *testing.T) { + t.Parallel() + resp := &soap.Response{Body: soap.ResponseBody{ReturnInfo: soap.Value{Kind: soap.KindArray}}} + c := mailforward.NewClient(&fakeCaller{resp: resp}) + if _, err := c.Get(context.Background(), "missing@example.de"); err == nil { + t.Errorf("Get on empty result err = nil, want not-found") + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := mailforward.NewClient(&fakeCaller{err: want}) + if _, err := c.List(context.Background()); !errors.Is(err, want) { + t.Errorf("List err = %v, want %v wrapped", err, want) + } + if _, err := c.Get(context.Background(), "from@example.de"); !errors.Is(err, want) { + t.Errorf("Get err = %v, want %v wrapped", err, want) + } +} + +func TestMailForwardListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailforwards_response_success.xml") + list, _ := mailforward.DecodeMailForwards(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0][0] != "from@example.de" || rows[0][1] != "to@example.de" { + t.Errorf("rows[0] = %v", rows[0]) + } +}