From e92e783b2122c10559d48dd675ed569a93235f18 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Fri, 8 May 2026 21:42:37 +0200 Subject: [PATCH] feat: mailing lists get_mailinglists read module + CLI Add `internal/mailinglist` with typed `MailingList` + `MailingListList`, `Client.List`, and the `kasapi-cli mail lists list` subcommand wrapping `get_mailinglists`. The endpoint takes no parameters and returns the Mailman lists provisioned for the account (name, admin, listinfo URL, in_progress). Mapping test runs against the shipped `testdata/mailinglist/` fixture. Closes #9. --- CHANGELOG.md | 6 ++ ROADMAP.md | 3 +- internal/cli/mail.go | 33 ++++++ internal/mailinglist/mailinglist.go | 99 ++++++++++++++++++ internal/mailinglist/mailinglist_test.go | 124 +++++++++++++++++++++++ 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 internal/mailinglist/mailinglist.go create mode 100644 internal/mailinglist/mailinglist_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b97606..0a43c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/mailinglist` read module and `kasapi-cli mail lists list` + subcommand wrapping `get_mailinglists`. Decodes the Array of + `{mailinglist_name, mailinglist_admin, mailinglist_url, in_progress}` + Maps into a typed `MailingListList` so callers can inspect the + Mailman lists provisioned for the account. Closes #9. + - `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 diff --git a/ROADMAP.md b/ROADMAP.md index 332c792..f13b232 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,7 +48,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl - [ ] Mail forward write paths (`add_mailforward`, `update_mailforward`, `delete_mailforward`) - [x] `mail filters list` (`get_mailstandardfilter`) - [ ] Mail standard filter write paths (`update_mailstandardfilter`) -- [ ] Mailing lists (`get_mailinglists`, `add_mailinglist`, `update_mailinglist`, `delete_mailinglist`) +- [x] `mail lists list` (`get_mailinglists`) +- [ ] Mailing list write paths (`add_mailinglist`, `update_mailinglist`, `delete_mailinglist`) ## Hosting resources diff --git a/internal/cli/mail.go b/internal/cli/mail.go index 7895679..6f02446 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -6,6 +6,7 @@ import ( "github.com/chmmou/kasapi-cli/internal/mailaccount" "github.com/chmmou/kasapi-cli/internal/mailfilter" "github.com/chmmou/kasapi-cli/internal/mailforward" + "github.com/chmmou/kasapi-cli/internal/mailinglist" ) // NewMailCmd returns the "kasapi-cli mail" subcommand tree, grouping @@ -20,10 +21,42 @@ func NewMailCmd(opts *RootOptions) *cobra.Command { newMailAccountsCmd(opts), newMailForwardsCmd(opts), newMailFiltersCmd(opts), + newMailListsCmd(opts), ) return cmd } +func newMailListsCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "lists", + Short: "Inspect mailing lists (get_mailinglists)", + } + cmd.AddCommand(newMailListsListCmd(opts)) + return cmd +} + +func newMailListsListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all mailing lists (get_mailinglists)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := mailinglist.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_mailinglists") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + func newMailFiltersCmd(opts *RootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "filters", diff --git a/internal/mailinglist/mailinglist.go b/internal/mailinglist/mailinglist.go new file mode 100644 index 0000000..9a07106 --- /dev/null +++ b/internal/mailinglist/mailinglist.go @@ -0,0 +1,99 @@ +package mailinglist + +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) +} + +// MailingList is one entry of get_mailinglists, describing a Mailman +// list provisioned for the account. +type MailingList struct { + Name string `json:"mailinglist_name" yaml:"mailinglist_name"` + Admin string `json:"mailinglist_admin" yaml:"mailinglist_admin"` + URL string `json:"mailinglist_url" yaml:"mailinglist_url"` + InProgress string `json:"in_progress" yaml:"in_progress"` +} + +// MailingListList is the typed payload of get_mailinglists; satisfies +// cli.Tabular. +type MailingListList []MailingList + +// Client groups the read endpoint scoped to mailing lists. +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_mailinglists and decodes the response into a +// MailingListList covering every mailing list visible to the login. +// The endpoint takes no parameters. +func (c *Client) List(ctx context.Context) (MailingListList, error) { + resp, err := c.API.Call(ctx, "get_mailinglists", nil) + if err != nil { + return nil, err + } + list, err := DecodeMailingLists(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("mailinglist: get_mailinglists: %w", err) + } + return list, nil +} + +// DecodeMailingLists maps the ReturnInfo of a get_mailinglists response +// (an Array of Maps) into the typed MailingListList. +func DecodeMailingLists(returnInfo soap.Value) (MailingListList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("mailinglist: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(MailingListList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("mailinglist: ReturnInfo[%d] is not a Map", i) + } + out = append(out, MailingList{ + Name: getString(item, "mailinglist_name"), + Admin: getString(item, "mailinglist_admin"), + URL: getString(item, "mailinglist_url"), + 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 +// MailingListList. +func (MailingListList) TableHeaders() []string { + return []string{"NAME", "ADMIN", "URL", "IN_PROGRESS"} +} + +// TableRows emits one row per MailingList entry. +func (l MailingListList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, m := range l { + rows = append(rows, []string{ + m.Name, + m.Admin, + m.URL, + m.InProgress, + }) + } + return rows +} diff --git a/internal/mailinglist/mailinglist_test.go b/internal/mailinglist/mailinglist_test.go new file mode 100644 index 0000000..4d94a49 --- /dev/null +++ b/internal/mailinglist/mailinglist_test.go @@ -0,0 +1,124 @@ +package mailinglist_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/mailinglist" + "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", "mailinglist", 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 TestDecodeMailingLists(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailinglists_response_success.xml") + got, err := mailinglist.DecodeMailingLists(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeMailingLists: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (per fixture arrayType)", len(got)) + } + m := got[0] + if m.Name != "announce@example.com" { + t.Errorf("Name = %q", m.Name) + } + if m.Admin != "admin@example.com" { + t.Errorf("Admin = %q", m.Admin) + } + if m.URL == "" { + t.Errorf("URL empty") + } +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailinglists_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := mailinglist.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_mailinglists" { + t.Errorf("action = %q, want get_mailinglists", 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 TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := mailinglist.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 TestMailingListListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_mailinglists_response_success.xml") + list, _ := mailinglist.DecodeMailingLists(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0][0] != "announce@example.com" { + t.Errorf("rows[0][0] = %q", rows[0][0]) + } +}