From 4ebe9f1f402e4929ba893d9394d549731114ea54 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Fri, 8 May 2026 21:24:02 +0200 Subject: [PATCH] feat: mail accounts get_mailaccounts read module + CLI Add `internal/mailaccount` with typed `MailAccount` + `MailAccountList`, `Client.List`/`Client.Get`, and the `kasapi-cli mail accounts list|get` subcommand tree. The `get` variant reuses `get_mailaccounts` with a `mail_login` filter and unwraps the single-entry result, mirroring the existing domains/subdomains pattern. Tabular views: list for the overview, key/value for the singular view to fit the wider field set (xlist folders, 2FA, quota rule, webmail autologin). Mapping tests run against the shipped `testdata/mailaccount/` fixtures. Refs #9. --- CHANGELOG.md | 12 ++ ROADMAP.md | 3 +- cmd/kasapi-cli/main.go | 1 + internal/cli/mail.go | 75 +++++++ internal/mailaccount/mailaccount.go | 256 +++++++++++++++++++++++ internal/mailaccount/mailaccount_test.go | 204 ++++++++++++++++++ 6 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 internal/cli/mail.go create mode 100644 internal/mailaccount/mailaccount.go create mode 100644 internal/mailaccount/mailaccount_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce055b..6fea554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `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 + `MailAccountList`; `get ` reuses the same endpoint with a + `mail_login` filter and unwraps the single-entry result. The + `--output=table` view shows login, address, used MB, responder flag + and active state; the singular view falls back to a key/value table + so the wider field set (xlist folders, 2FA flag, quota rule, webmail + autologin) stays readable. Mapping tests run against + `testdata/mailaccount/get_mailaccounts_response_success.xml` and + `get_mailaccount_response_success.xml`. Refs #9. + - `kasapi-cli subdomains get ` calls `get_subdomains` with a `subdomain_name` filter and unwraps the single-entry result, mirroring the existing `domains get` flow; the singular `Subdomain` value diff --git a/ROADMAP.md b/ROADMAP.md index 90ac129..21fda61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl ## Mail -- [ ] Mail accounts (`get_mailaccounts`, `add_mailaccount`, `update_mailaccount`, `delete_mailaccount`) +- [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`) - [ ] Mail standard filters (`get_mailstandardfilter`, `update_mailstandardfilter`) - [ ] Mailing lists (`get_mailinglists`, `add_mailinglist`, `update_mailinglist`, `delete_mailinglist`) diff --git a/cmd/kasapi-cli/main.go b/cmd/kasapi-cli/main.go index 6a092c8..298c7e5 100644 --- a/cmd/kasapi-cli/main.go +++ b/cmd/kasapi-cli/main.go @@ -21,6 +21,7 @@ func main() { cli.NewSubdomainsCmd(opts), cli.NewTLDsCmd(opts), cli.NewDNSCmd(opts), + cli.NewMailCmd(opts), cli.NewUsageCmd(opts), cli.NewConfigCmd(opts), ) diff --git a/internal/cli/mail.go b/internal/cli/mail.go new file mode 100644 index 0000000..e88ed47 --- /dev/null +++ b/internal/cli/mail.go @@ -0,0 +1,75 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/mailaccount" +) + +// NewMailCmd returns the "kasapi-cli mail" subcommand tree, grouping +// the mail-related read endpoints (mail accounts; later forwards, +// filters, mailing lists). +func NewMailCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "mail", + Short: "Inspect mail accounts, forwards, filters, and mailing lists", + } + cmd.AddCommand(newMailAccountsCmd(opts)) + return cmd +} + +func newMailAccountsCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "accounts", + Short: "Inspect mail accounts (get_mailaccounts)", + } + cmd.AddCommand( + newMailAccountsListCmd(opts), + newMailAccountsGetCmd(opts), + ) + return cmd +} + +func newMailAccountsListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all mail accounts (get_mailaccounts)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := mailaccount.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_mailaccounts") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + +func newMailAccountsGetCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show details for a single mail account (get_mailaccounts with mail_login)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + a, err := mailaccount.NewClient(api).Get(cmd.Context(), args[0]) + if err != nil { + return APIError(err, "get_mailaccounts") + } + if err := Render(cmd.OutOrStdout(), opts.Output, a); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} diff --git a/internal/mailaccount/mailaccount.go b/internal/mailaccount/mailaccount.go new file mode 100644 index 0000000..92ceadd --- /dev/null +++ b/internal/mailaccount/mailaccount.go @@ -0,0 +1,256 @@ +package mailaccount + +import ( + "context" + "fmt" + "strconv" + "strings" + + "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: a fake Caller can +// return a *soap.Response decoded from a fixture. +type Caller interface { + Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error) +} + +// MailAccount is one entry of get_mailaccounts. The list and singular +// views (the latter being get_mailaccounts called with a mail_login +// filter) return the same Map shape, so a single struct covers both. +type MailAccount struct { + Login string `json:"mail_login" yaml:"mail_login"` + Password string `json:"mail_password,omitempty" yaml:"mail_password,omitempty"` + + // The KAS API returns both the legacy mail_adresses key (single d) + // and the canonical mail_addresses key with identical content. We + // keep both in the struct so a JSON/YAML round-trip preserves the + // raw payload, but consumers should prefer Addresses. + Adresses string `json:"mail_adresses" yaml:"mail_adresses"` + Addresses string `json:"mail_addresses" yaml:"mail_addresses"` + + Comment string `json:"mail_comment" yaml:"mail_comment"` + + Responder string `json:"mail_responder" yaml:"mail_responder"` + ResponderText string `json:"mail_responder_text" yaml:"mail_responder_text"` + ResponderDisplayName string `json:"mail_responder_displayname" yaml:"mail_responder_displayname"` + ResponderContentType string `json:"mail_responder_content_type" yaml:"mail_responder_content_type"` + + // Same legacy/canonical pair as for the addresses. + CopyAdress string `json:"mail_copy_adress" yaml:"mail_copy_adress"` + CopyAddress string `json:"mail_copy_address" yaml:"mail_copy_address"` + + SenderAlias string `json:"mail_sender_alias" yaml:"mail_sender_alias"` + Spamfilter string `json:"mail_spamfilter" yaml:"mail_spamfilter"` + + InProgress string `json:"in_progress" yaml:"in_progress"` + + XListEnabled string `json:"mail_xlist_enabled" yaml:"mail_xlist_enabled"` + XListSent string `json:"mail_xlist_sent" yaml:"mail_xlist_sent"` + XListDrafts string `json:"mail_xlist_drafts" yaml:"mail_xlist_drafts"` + XListTrash string `json:"mail_xlist_trash" yaml:"mail_xlist_trash"` + XListSpam string `json:"mail_xlist_spam" yaml:"mail_xlist_spam"` + XListArchiv string `json:"mail_xlist_archiv" yaml:"mail_xlist_archiv"` + + UsedSpace float64 `json:"used_mailaccount_space" yaml:"used_mailaccount_space"` + IsActive string `json:"mail_is_active" yaml:"mail_is_active"` + ShowPassword string `json:"show_password" yaml:"show_password"` + AllowNets string `json:"mail_allow_nets" yaml:"mail_allow_nets"` + TwoFA string `json:"mail_2fa" yaml:"mail_2fa"` + QuotaRule int `json:"quota_rule" yaml:"quota_rule"` + WebmailAutologin string `json:"webmail_autologin" yaml:"webmail_autologin"` +} + +// MailAccountList is the typed payload of get_mailaccounts; satisfies +// cli.Tabular. +type MailAccountList []MailAccount + +// Client groups the read endpoints scoped to mail accounts: +// get_mailaccounts (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_mailaccounts without parameters and decodes the +// response into a MailAccountList covering every mail account visible +// to the login. +func (c *Client) List(ctx context.Context) (MailAccountList, error) { + resp, err := c.API.Call(ctx, "get_mailaccounts", nil) + if err != nil { + return nil, err + } + list, err := DecodeMailAccounts(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("mailaccount: get_mailaccounts: %w", err) + } + return list, nil +} + +// Get calls get_mailaccounts with a mail_login filter and returns the +// single matching MailAccount. The KAS API still wraps the result in +// an array; we unwrap it here so callers do not have to. An empty +// array surfaces as a not-found error. +func (c *Client) Get(ctx context.Context, login string) (MailAccount, error) { + if login == "" { + return MailAccount{}, fmt.Errorf("mailaccount: login is required") + } + resp, err := c.API.Call(ctx, "get_mailaccounts", map[string]any{"mail_login": login}) + if err != nil { + return MailAccount{}, err + } + list, err := DecodeMailAccounts(resp.Body.ReturnInfo) + if err != nil { + return MailAccount{}, fmt.Errorf("mailaccount: get_mailaccounts: %w", err) + } + if len(list) == 0 { + return MailAccount{}, fmt.Errorf("mailaccount: %q not found", login) + } + return list[0], nil +} + +// DecodeMailAccounts maps the ReturnInfo of a get_mailaccounts response +// (an Array of Maps) into the typed MailAccountList. +func DecodeMailAccounts(returnInfo soap.Value) (MailAccountList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("mailaccount: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(MailAccountList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("mailaccount: ReturnInfo[%d] is not a Map", i) + } + out = append(out, MailAccount{ + Login: getString(item, "mail_login"), + Password: getString(item, "mail_password"), + Adresses: getString(item, "mail_adresses"), + Addresses: getString(item, "mail_addresses"), + Comment: getString(item, "mail_comment"), + Responder: getString(item, "mail_responder"), + ResponderText: getString(item, "mail_responder_text"), + ResponderDisplayName: getString(item, "mail_responder_displayname"), + ResponderContentType: getString(item, "mail_responder_content_type"), + CopyAdress: getString(item, "mail_copy_adress"), + CopyAddress: getString(item, "mail_copy_address"), + SenderAlias: getString(item, "mail_sender_alias"), + Spamfilter: getString(item, "mail_spamfilter"), + InProgress: getString(item, "in_progress"), + XListEnabled: getString(item, "mail_xlist_enabled"), + XListSent: getString(item, "mail_xlist_sent"), + XListDrafts: getString(item, "mail_xlist_drafts"), + XListTrash: getString(item, "mail_xlist_trash"), + XListSpam: getString(item, "mail_xlist_spam"), + XListArchiv: getString(item, "mail_xlist_archiv"), + UsedSpace: getFloat(item, "used_mailaccount_space"), + IsActive: getString(item, "mail_is_active"), + ShowPassword: getString(item, "show_password"), + AllowNets: getString(item, "mail_allow_nets"), + TwoFA: getString(item, "mail_2fa"), + QuotaRule: getInt(item, "quota_rule"), + WebmailAutologin: getString(item, "webmail_autologin"), + }) + } + return out, nil +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +func getInt(m soap.Value, key string) int { + v, ok := m.Get(key) + if !ok { + return 0 + } + switch v.Kind { + case soap.KindInt: + return int(v.Int) + case soap.KindFloat: + return int(v.Float) + case soap.KindString: + s := strings.TrimSpace(v.String) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n + } + return 0 +} + +func getFloat(m soap.Value, key string) float64 { + v, ok := m.Get(key) + if !ok { + return 0 + } + return v.AsFloat() +} + +// TableHeaders returns the columns used by --output=table for +// MailAccountList. +func (MailAccountList) TableHeaders() []string { + return []string{"LOGIN", "ADDRESS", "USED_MB", "RESPONDER", "ACTIVE", "IN_PROGRESS"} +} + +// TableRows emits one row per MailAccount entry. +func (l MailAccountList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, a := range l { + rows = append(rows, []string{ + a.Login, + a.Addresses, + strconv.FormatFloat(a.UsedSpace, 'f', 2, 64), + a.Responder, + a.IsActive, + a.InProgress, + }) + } + return rows +} + +// TableHeaders for the singular MailAccount view: a key/value layout +// to fit the wider field set. +func (MailAccount) TableHeaders() []string { + return []string{"FIELD", "VALUE"} +} + +// TableRows emits the scalar fields. The redundant mail_adresses / +// mail_copy_adress legacy keys are omitted from the table; they remain +// available via --output=json|yaml. +func (a MailAccount) TableRows() [][]string { + return [][]string{ + {"mail_login", a.Login}, + {"mail_addresses", a.Addresses}, + {"mail_comment", a.Comment}, + {"mail_responder", a.Responder}, + {"mail_responder_displayname", a.ResponderDisplayName}, + {"mail_responder_content_type", a.ResponderContentType}, + {"mail_copy_address", a.CopyAddress}, + {"mail_sender_alias", a.SenderAlias}, + {"mail_spamfilter", a.Spamfilter}, + {"mail_xlist_enabled", a.XListEnabled}, + {"mail_xlist_sent", a.XListSent}, + {"mail_xlist_drafts", a.XListDrafts}, + {"mail_xlist_trash", a.XListTrash}, + {"mail_xlist_spam", a.XListSpam}, + {"mail_xlist_archiv", a.XListArchiv}, + {"used_mailaccount_space", strconv.FormatFloat(a.UsedSpace, 'f', 2, 64)}, + {"mail_is_active", a.IsActive}, + {"show_password", a.ShowPassword}, + {"mail_allow_nets", a.AllowNets}, + {"mail_2fa", a.TwoFA}, + {"quota_rule", strconv.Itoa(a.QuotaRule)}, + {"webmail_autologin", a.WebmailAutologin}, + {"in_progress", a.InProgress}, + } +} diff --git a/internal/mailaccount/mailaccount_test.go b/internal/mailaccount/mailaccount_test.go new file mode 100644 index 0000000..e4acd8d --- /dev/null +++ b/internal/mailaccount/mailaccount_test.go @@ -0,0 +1,204 @@ +package mailaccount_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/mailaccount" + "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", "mailaccount", 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 TestDecodeMailAccounts(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailaccounts_response_success.xml") + got, err := mailaccount.DecodeMailAccounts(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeMailAccounts: %v", err) + } + if len(got) != 15 { + t.Fatalf("len = %d, want 15 (per fixture arrayType)", len(got)) + } + a := got[0] + if a.Login != "m0000001" { + t.Errorf("Login = %q, want m0000001", a.Login) + } + if a.Addresses != "m0000001@example.com" || a.Adresses != "m0000001@example.com" { + t.Errorf("addresses pair = %q / %q", a.Addresses, a.Adresses) + } + if a.Spamfilter != "pdw,sf" { + t.Errorf("Spamfilter = %q", a.Spamfilter) + } + if a.UsedSpace == 0 { + t.Errorf("UsedSpace = 0, want non-zero from xsd:float") + } + if a.IsActive != "Y" { + t.Errorf("IsActive = %q", a.IsActive) + } + if a.WebmailAutologin != "Y" { + t.Errorf("WebmailAutologin = %q", a.WebmailAutologin) + } +} + +func TestDecodeMailAccountSingular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailaccount_response_success.xml") + got, err := mailaccount.DecodeMailAccounts(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeMailAccounts: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + a := got[0] + if a.Login == "" { + t.Errorf("Login empty") + } + if a.UsedSpace == 0 { + t.Errorf("UsedSpace = 0, want non-zero") + } +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailaccounts_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := mailaccount.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_mailaccounts" { + t.Errorf("action = %q, want get_mailaccounts", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 15 { + t.Errorf("len = %d, want 15", len(list)) + } +} + +func TestClientGet(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailaccount_response_success.xml") + fc := &fakeCaller{resp: resp} + a, err := mailaccount.NewClient(fc).Get(context.Background(), "m0000001") + if err != nil { + t.Fatalf("Get: %v", err) + } + if fc.gotAction != "get_mailaccounts" { + t.Errorf("action = %q, want get_mailaccounts", fc.gotAction) + } + if got, _ := fc.gotParams["mail_login"].(string); got != "m0000001" { + t.Errorf("params[mail_login] = %v, want m0000001", fc.gotParams["mail_login"]) + } + if a.Login == "" { + t.Errorf("Login empty") + } +} + +func TestClientGetEmptyLogin(t *testing.T) { + t.Parallel() + c := mailaccount.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 := mailaccount.NewClient(&fakeCaller{resp: resp}) + if _, err := c.Get(context.Background(), "missing"); 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 := mailaccount.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(), "m0000001"); !errors.Is(err, want) { + t.Errorf("Get err = %v, want %v wrapped", err, want) + } +} + +func TestMailAccountListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailaccounts_response_success.xml") + list, _ := mailaccount.DecodeMailAccounts(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 15 { + t.Fatalf("rows = %d, want 15", len(rows)) + } + if rows[0][0] != "m0000001" { + t.Errorf("rows[0][0] = %q, want m0000001", rows[0][0]) + } +} + +func TestMailAccountTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailaccount_response_success.xml") + list, _ := mailaccount.DecodeMailAccounts(resp.Body.ReturnInfo) + rows := list[0].TableRows() + if len(rows) == 0 { + t.Fatal("no rows") + } + if rows[0][0] != "mail_login" { + t.Errorf("rows[0][0] = %q, want mail_login", rows[0][0]) + } +}