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

Expand Down
62 changes: 61 additions & 1 deletion internal/cli/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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 <address>",
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
},
}
}
152 changes: 152 additions & 0 deletions internal/mailforward/mailforward.go
Original file line number Diff line number Diff line change
@@ -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},
}
}
Loading
Loading