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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions internal/cli/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions internal/mailinglist/mailinglist.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 124 additions & 0 deletions internal/mailinglist/mailinglist_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading