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])
+ }
+}