From 637ec4aa0c8dd79bddf2dd15ef191971cca93c88 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Fri, 22 May 2026 17:59:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail):=20mailaccount=20write=20endpoints?= =?UTF-8?q?=20=E2=80=94=20add=20/=20update=20/=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire add_mailaccount / update_mailaccount / delete_mailaccount through the shared kaswrite seam. update and delete are gated by the #109 confirmation prompt; add is reversible and not prompted. All three honour --dry-run and emit a #131 audit record with the password redacted. add splits the address into local_part/domain_part and takes no mail_login (KAS generates it, echoed in ReturnInfo). The password key splits between actions: --password maps to mail_password on add and mail_new_password on update. delete_mailaccount uses the louder "permanently delete" verb (drops the mailbox and all messages), shared with delete_database. Read fixtures were refreshed to real responses; the read mapping tests were realigned (empty mailbox => used_mailaccount_space 0). Closes #114 --- CHANGELOG.md | 23 ++ docs/cli/kasapi-cli_mail.md | 2 +- docs/cli/kasapi-cli_mail_accounts.md | 5 +- docs/cli/kasapi-cli_mail_accounts_add.md | 62 ++++ docs/cli/kasapi-cli_mail_accounts_delete.md | 36 +++ docs/cli/kasapi-cli_mail_accounts_get.md | 2 +- docs/cli/kasapi-cli_mail_accounts_list.md | 2 +- docs/cli/kasapi-cli_mail_accounts_update.md | 52 ++++ docs/usage/destructive-writes.md | 5 +- internal/cli/export_test.go | 15 +- internal/cli/mail.go | 272 +++++++++++++++++- internal/cli/mail_test.go | 220 +++++++++++++- internal/mailaccount/fault_test.go | 69 ++++- internal/mailaccount/mailaccount.go | 28 +- internal/mailaccount/mailaccount_test.go | 13 +- internal/mailaccount/write.go | 194 +++++++++++++ internal/mailaccount/write_test.go | 215 ++++++++++++++ .../mailaccount/add_mailaccount_request.xml | 5 +- ...onse_failed_couldnt_get_kas_ressources.xml | 11 + ...led_mail_xlist_archiv_syntax_incorrect.xml | 11 + ...led_mail_xlist_drafts_syntax_incorrect.xml | 11 + ...nse_failed_mail_xlist_duplicate_folder.xml | 11 + ...ed_mail_xlist_enabled_syntax_incorrect.xml | 11 + ...ailed_mail_xlist_sent_syntax_incorrect.xml | 11 + ...ailed_mail_xlist_spam_syntax_incorrect.xml | 11 + ...iled_mail_xlist_trash_syntax_incorrect.xml | 11 + ..._responder_contentype_syntax_incorrect.xml | 11 + ...responder_displayname_syntax_incorrect.xml | 11 + ...nder_not_allowed_for_catchall_adresses.xml | 11 + ..._failed_responder_startdate_gt_enddate.xml | 11 + ...onse_failed_sender_alias_domain_in_kas.xml | 11 + .../add_mailaccount_response_success.xml | 26 +- .../delete_mailaccount_response_success.xml | 6 +- .../get_mailaccount_response_success.xml | 20 +- .../get_mailaccounts_response_success.xml | 4 +- .../update_mailaccount_request.xml | 11 +- ...ponse_failed_email_domain_doesnt_exist.xml | 11 + ...onse_failed_is_active_syntax_incorrect.xml | 11 + ...led_mail_xlist_archiv_syntax_incorrect.xml | 11 + ...led_mail_xlist_drafts_syntax_incorrect.xml | 11 + ...nse_failed_mail_xlist_duplicate_folder.xml | 11 + ...ed_mail_xlist_enabled_syntax_incorrect.xml | 11 + ...ailed_mail_xlist_sent_syntax_incorrect.xml | 11 + ...ailed_mail_xlist_spam_syntax_incorrect.xml | 11 + ...iled_mail_xlist_trash_syntax_incorrect.xml | 11 + ..._responder_contentype_syntax_incorrect.xml | 11 + ...responder_displayname_syntax_incorrect.xml | 11 + ...nder_not_allowed_for_catchall_adresses.xml | 11 + ..._failed_responder_startdate_gt_enddate.xml | 11 + ...onse_failed_sender_alias_domain_in_kas.xml | 11 + .../update_mailaccount_response_success.xml | 52 +++- 51 files changed, 1574 insertions(+), 62 deletions(-) create mode 100644 docs/cli/kasapi-cli_mail_accounts_add.md create mode 100644 docs/cli/kasapi-cli_mail_accounts_delete.md create mode 100644 docs/cli/kasapi-cli_mail_accounts_update.md create mode 100644 internal/mailaccount/write.go create mode 100644 internal/mailaccount/write_test.go create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_couldnt_get_kas_ressources.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_duplicate_folder.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_responder_startdate_gt_enddate.xml create mode 100644 testdata/mailaccount/add_mailaccount_response_failed_sender_alias_domain_in_kas.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_email_domain_doesnt_exist.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_is_active_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_duplicate_folder.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_responder_startdate_gt_enddate.xml create mode 100644 testdata/mailaccount/update_mailaccount_response_failed_sender_alias_domain_in_kas.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 50de8a7..0f5f084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Mail account write endpoints (#114, #13 write slice): + `kasapi-cli mail accounts add
--password [field flags]`, + `… update [field flags]` and + `… delete ` wire `add_mailaccount` / `update_mailaccount` + / `delete_mailaccount`. `update` and `delete` are gated by the #109 + confirmation prompt; `add` is reversible and not prompted. All three + honour `--dry-run` (#132) and emit a #131 audit record; the password + is redacted in both. `add` splits the address on the last `@` into + the `local_part` / `domain_part` KAS expects and takes no + `mail_login` — KAS generates the login (e.g. `m0000001`) and echoes + it in `ReturnInfo`, which the command prints. The Y/N/text toggles + and XLIST folder names default to the KAS API's own defaults, so a + bare `add
--password ` is a complete create. `update` + sends only the explicitly-set flags (keyed on cobra `Changed`), adds + the `--active` (`is_active`) toggle, and its `--password` maps to + `mail_new_password` (the `_new_password` split the + database/ftpuser/sambauser slices carry) rather than the add-only + `mail_password`. `responder` is passed through verbatim ("N", "Y" or + a `|` timestamp range). `delete_mailaccount`'s prompt + uses the louder verb "permanently delete" — it drops the mailbox and + every message in it (the same data-loss emphasis as + `delete_database`). + - `database.InProgressFalse` / `database.InProgressTrue` package constants for the literal `"FALSE"` / `"TRUE"` strings the KAS API uses to encode the async-write flag, so mapping code and tests diff --git a/docs/cli/kasapi-cli_mail.md b/docs/cli/kasapi-cli_mail.md index 7198155..00c15cb 100644 --- a/docs/cli/kasapi-cli_mail.md +++ b/docs/cli/kasapi-cli_mail.md @@ -29,7 +29,7 @@ Inspect mail accounts and filters; inspect and manage forwards and mailing lists ### SEE ALSO * [kasapi-cli](kasapi-cli.md) - Command-line client for the All-Inkl KAS API -* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect mail accounts (get_mailaccounts) +* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect and manage mail accounts (get/add/update/delete_mailaccount) * [kasapi-cli mail filters](kasapi-cli_mail_filters.md) - Inspect mail standard filters (get_mailstandardfilter) * [kasapi-cli mail forwards](kasapi-cli_mail_forwards.md) - Inspect and manage mail forwards (get/add/update/delete_mailforward) * [kasapi-cli mail lists](kasapi-cli_mail_lists.md) - Inspect and manage mailing lists (get/add/update/delete_mailinglist) diff --git a/docs/cli/kasapi-cli_mail_accounts.md b/docs/cli/kasapi-cli_mail_accounts.md index 197ec20..b683a5c 100644 --- a/docs/cli/kasapi-cli_mail_accounts.md +++ b/docs/cli/kasapi-cli_mail_accounts.md @@ -1,6 +1,6 @@ ## kasapi-cli mail accounts -Inspect mail accounts (get_mailaccounts) +Inspect and manage mail accounts (get/add/update/delete_mailaccount) ### Options @@ -29,6 +29,9 @@ Inspect mail accounts (get_mailaccounts) ### SEE ALSO * [kasapi-cli mail](kasapi-cli_mail.md) - Inspect mail accounts and filters; inspect and manage forwards and mailing lists +* [kasapi-cli mail accounts add](kasapi-cli_mail_accounts_add.md) - Create a mail account (add_mailaccount; the login is generated by KAS) +* [kasapi-cli mail accounts delete](kasapi-cli_mail_accounts_delete.md) - Permanently delete a mail account and all messages in it (delete_mailaccount) * [kasapi-cli mail accounts get](kasapi-cli_mail_accounts_get.md) - Show details for a single mail account (get_mailaccounts with mail_login) * [kasapi-cli mail accounts list](kasapi-cli_mail_accounts_list.md) - List all mail accounts (get_mailaccounts) +* [kasapi-cli mail accounts update](kasapi-cli_mail_accounts_update.md) - Replace mutable fields of a mail account (update_mailaccount) diff --git a/docs/cli/kasapi-cli_mail_accounts_add.md b/docs/cli/kasapi-cli_mail_accounts_add.md new file mode 100644 index 0000000..44557f2 --- /dev/null +++ b/docs/cli/kasapi-cli_mail_accounts_add.md @@ -0,0 +1,62 @@ +## kasapi-cli mail accounts add + +Create a mail account (add_mailaccount; the login is generated by KAS) + +### Synopsis + +Create a mail account via add_mailaccount. The address is split on the +last '@' into the local_part / domain_part KAS expects, and KAS +generates the mail login (e.g. m0000001), which the command prints on +success. + +The Y/N/text toggles and XLIST folder names default to the KAS API's +own defaults, so "accounts add info@example.com --password " is a +complete create; override any field with its flag. + +``` +kasapi-cli mail accounts add
--password [field flags] [flags] +``` + +### Options + +``` + --allow-nets string restrict access to these IP/CIDR networks (empty = no restriction) + --copy-address string BCC copy recipient address(es) + -h, --help help for add + --password string initial mailbox password (required) + --responder string auto-responder: "N", "Y", or a "|" timestamp range (default "N") + --responder-content-type string auto-responder body format (html|text) (default "text") + --responder-displayname string auto-responder sender display name + --responder-text string auto-responder message body + --sender-alias string permitted FROM alias address(es) + --webmail-autologin string allow KAS-to-webmail auto-login (Y|N) (default "Y") + --xlist-archiv string XLIST archive folder name (default "Archive") + --xlist-drafts string XLIST drafts folder name (default "Drafts") + --xlist-enabled string enable XLIST special-folder mapping (Y|N) (default "Y") + --xlist-sent string XLIST sent-items folder name (default "Sent") + --xlist-spam string XLIST spam folder name (default "Spam") + --xlist-trash string XLIST trash folder name (default "Trash") +``` + +### Options inherited from parent commands + +``` + --audit-log string append a JSON-Lines audit record for each write action to this file (also KAS_AUDIT_LOG); a logfmt line always goes to stderr regardless + --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) + --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. + --config string path to the kasapi-cli config file (overrides the default location) + --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --login string KAS login (overrides config and KAS_LOGIN) + --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. + -o, --output string output format: json|yaml|table (default table) + --profile string profile to select from the config file (overrides default_profile) + --session-lifetime int session_lifetime in seconds passed to KasAuth (1..30000); 0 keeps the server default. Requires auth_type=session. + --session-update-lifetime string session_update_lifetime passed to KasAuth ('Y' = sliding window, 'N' = fixed). Empty omits the parameter. Requires auth_type=session. + -v, --verbose enable verbose logging on stderr + -y, --yes skip confirmation prompts on destructive operations +``` + +### SEE ALSO + +* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect and manage mail accounts (get/add/update/delete_mailaccount) + diff --git a/docs/cli/kasapi-cli_mail_accounts_delete.md b/docs/cli/kasapi-cli_mail_accounts_delete.md new file mode 100644 index 0000000..6ab52c9 --- /dev/null +++ b/docs/cli/kasapi-cli_mail_accounts_delete.md @@ -0,0 +1,36 @@ +## kasapi-cli mail accounts delete + +Permanently delete a mail account and all messages in it (delete_mailaccount) + +``` +kasapi-cli mail accounts delete [flags] +``` + +### Options + +``` + -h, --help help for delete +``` + +### Options inherited from parent commands + +``` + --audit-log string append a JSON-Lines audit record for each write action to this file (also KAS_AUDIT_LOG); a logfmt line always goes to stderr regardless + --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) + --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. + --config string path to the kasapi-cli config file (overrides the default location) + --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --login string KAS login (overrides config and KAS_LOGIN) + --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. + -o, --output string output format: json|yaml|table (default table) + --profile string profile to select from the config file (overrides default_profile) + --session-lifetime int session_lifetime in seconds passed to KasAuth (1..30000); 0 keeps the server default. Requires auth_type=session. + --session-update-lifetime string session_update_lifetime passed to KasAuth ('Y' = sliding window, 'N' = fixed). Empty omits the parameter. Requires auth_type=session. + -v, --verbose enable verbose logging on stderr + -y, --yes skip confirmation prompts on destructive operations +``` + +### SEE ALSO + +* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect and manage mail accounts (get/add/update/delete_mailaccount) + diff --git a/docs/cli/kasapi-cli_mail_accounts_get.md b/docs/cli/kasapi-cli_mail_accounts_get.md index 7ad0815..9ee0ebd 100644 --- a/docs/cli/kasapi-cli_mail_accounts_get.md +++ b/docs/cli/kasapi-cli_mail_accounts_get.md @@ -32,5 +32,5 @@ kasapi-cli mail accounts get [flags] ### SEE ALSO -* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect mail accounts (get_mailaccounts) +* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect and manage mail accounts (get/add/update/delete_mailaccount) diff --git a/docs/cli/kasapi-cli_mail_accounts_list.md b/docs/cli/kasapi-cli_mail_accounts_list.md index a127654..5b01d1c 100644 --- a/docs/cli/kasapi-cli_mail_accounts_list.md +++ b/docs/cli/kasapi-cli_mail_accounts_list.md @@ -32,5 +32,5 @@ kasapi-cli mail accounts list [flags] ### SEE ALSO -* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect mail accounts (get_mailaccounts) +* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect and manage mail accounts (get/add/update/delete_mailaccount) diff --git a/docs/cli/kasapi-cli_mail_accounts_update.md b/docs/cli/kasapi-cli_mail_accounts_update.md new file mode 100644 index 0000000..2af6460 --- /dev/null +++ b/docs/cli/kasapi-cli_mail_accounts_update.md @@ -0,0 +1,52 @@ +## kasapi-cli mail accounts update + +Replace mutable fields of a mail account (update_mailaccount) + +``` +kasapi-cli mail accounts update [field flags] [flags] +``` + +### Options + +``` + --active string mailbox status (Y|N) + --allow-nets string restrict access to these IP/CIDR networks + --copy-address string BCC copy recipient address(es) + -h, --help help for update + --password string replacement mailbox password (sent as mail_new_password) + --responder string auto-responder: "N", "Y", or a "|" timestamp range + --responder-content-type string auto-responder body format (html|text) + --responder-displayname string auto-responder sender display name + --responder-text string auto-responder message body + --sender-alias string permitted FROM alias address(es) + --webmail-autologin string allow KAS-to-webmail auto-login (Y|N) + --xlist-archiv string XLIST archive folder name + --xlist-drafts string XLIST drafts folder name + --xlist-enabled string enable XLIST special-folder mapping (Y|N) + --xlist-sent string XLIST sent-items folder name + --xlist-spam string XLIST spam folder name + --xlist-trash string XLIST trash folder name +``` + +### Options inherited from parent commands + +``` + --audit-log string append a JSON-Lines audit record for each write action to this file (also KAS_AUDIT_LOG); a logfmt line always goes to stderr regardless + --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) + --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. + --config string path to the kasapi-cli config file (overrides the default location) + --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --login string KAS login (overrides config and KAS_LOGIN) + --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. + -o, --output string output format: json|yaml|table (default table) + --profile string profile to select from the config file (overrides default_profile) + --session-lifetime int session_lifetime in seconds passed to KasAuth (1..30000); 0 keeps the server default. Requires auth_type=session. + --session-update-lifetime string session_update_lifetime passed to KasAuth ('Y' = sliding window, 'N' = fixed). Empty omits the parameter. Requires auth_type=session. + -v, --verbose enable verbose logging on stderr + -y, --yes skip confirmation prompts on destructive operations +``` + +### SEE ALSO + +* [kasapi-cli mail accounts](kasapi-cli_mail_accounts.md) - Inspect and manage mail accounts (get/add/update/delete_mailaccount) + diff --git a/docs/usage/destructive-writes.md b/docs/usage/destructive-writes.md index 4385e97..cbcc96e 100644 --- a/docs/usage/destructive-writes.md +++ b/docs/usage/destructive-writes.md @@ -30,8 +30,8 @@ the deviations. sinks. - Subcommands whose KAS action generates the login server-side (`add_ftpuser`, `add_sambauser`, `add_ddnsuser`, `add_database`, - `add_mailinglist`, `add_mailforward`) print the generated identifier - on success. + `add_mailinglist`, `add_mailforward`, `add_mailaccount`) print the + generated identifier on success. ## Per-slice deviations @@ -44,6 +44,7 @@ the deviations. | [`sambausers`](https://github.com/chmmou/kasapi-cli/issues/120) | Password key splits between actions: `--password` → `samba_password` on add, `samba_new_password` on update. Note: the KAS docs wrongly list `samba_new_password` for the create call; the captured fixture confirms the real key is `samba_password`. | | [`databases`](https://github.com/chmmou/kasapi-cli/issues/122) | **Louder delete prompt**: `delete_database` uses the verb `"permanently delete"` (vs the bare `"delete"` every other slice uses) because the action drops the database AND every row in it — the loudest data-loss surface of the v0.2.0 write phase. Password key splits between actions: `--password` → `database_password` on add, `database_new_password` on update. `--allowed-hosts` is **optional**: an empty value is the KAS API's documented "any host may connect" wildcard, sent verbatim on the wire. | | [`ddnsusers`](https://github.com/chmmou/kasapi-cli/issues/121) | **No `_new_password` split**: `--password` maps to `dyndns_password` on both `add` and `update`. `update_ddnsuser` accepts `--target-ipv4` / `--target-ipv6` instead of `add`'s legacy `--target-ip`; the ipv4/ipv6 keys are undocumented in the KAS API docs but verified to work against the live system (the captured update request fixture is authoritative). | +| [`mail accounts`](https://github.com/chmmou/kasapi-cli/issues/114) | **Louder delete prompt**: `delete_mailaccount` uses `"permanently delete"` (shared with `databases`) — it drops the mailbox AND every message in it. `add` splits the address on the last `@` into `local_part` / `domain_part`; `update`/`delete` address the account by its generated `mail_login`. Password key splits between actions: `--password` → `mail_password` on add, `mail_new_password` on update. `add`'s Y/N/text toggles and XLIST folder names default to the KAS API's own defaults; `--responder` is passed through verbatim (`N`, `Y`, or a `\|` timestamp range). | ## The contract diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go index eec5883..4a735ad 100644 --- a/internal/cli/export_test.go +++ b/internal/cli/export_test.go @@ -34,8 +34,13 @@ var RevokeSession = revokeSession // temp session.Store, mirroring the `config use-profile` pattern. var RunSessionsDelete = runSessionsDelete -// DatabaseDeleteConfirm exposes the package-private helper that -// builds the ConfirmAction for delete_database. Tests use it to pin -// the "permanently delete" loudness adjustment (database is the only -// slice using that emphatic verb). -var DatabaseDeleteConfirm = databaseDeleteConfirm +// DatabaseDeleteConfirm and MailAccountDeleteConfirm expose the +// package-private helpers that build the delete ConfirmAction for their +// slices. Tests use them to pin the "permanently delete" loudness +// adjustment — the two data-loss deletes (a database drops all rows, a +// mail account drops all stored messages) that use the emphatic verb +// instead of the bare "delete" every other slice uses. +var ( + DatabaseDeleteConfirm = databaseDeleteConfirm + MailAccountDeleteConfirm = mailAccountDeleteConfirm +) diff --git a/internal/cli/mail.go b/internal/cli/mail.go index a1d513f..f89325f 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -210,11 +210,14 @@ func newMailFiltersListCmd(opts *RootOptions) *cobra.Command { func newMailAccountsCmd(opts *RootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "accounts", - Short: "Inspect mail accounts (get_mailaccounts)", + Short: "Inspect and manage mail accounts (get/add/update/delete_mailaccount)", } cmd.AddCommand( newMailAccountsListCmd(opts), newMailAccountsGetCmd(opts), + newMailAccountsAddCmd(opts), + newMailAccountsUpdateCmd(opts), + newMailAccountsDeleteCmd(opts), ) return cmd } @@ -241,6 +244,265 @@ func newMailAccountsGetCmd(opts *RootOptions) *cobra.Command { } } +// mailAccountAddFlags binds the add_mailaccount request fields. add and +// update bind disjoint flag sets (same wire-key surface, but each +// subcommand's --help reflects only its own action semantics, and cobra +// rejects a wrong-subcommand flag at parse time). The password flag maps +// to mail_password here (the add-only key); update has its own +// --password flag that maps to mail_new_password. +// +// The Y/N/text toggles and the XLIST folder names default to the values +// the KAS API documents as its own defaults (and that the captured +// add_mailaccount request fixture carries), so a bare +// "accounts add --password " produces a valid create. +type mailAccountAddFlags struct { + password string + webmailAutologin string + responder string + responderContentType string + responderDisplayName string + responderText string + copyAddress string + senderAlias string + xlistEnabled string + xlistSent string + xlistDrafts string + xlistTrash string + xlistSpam string + xlistArchiv string + allowNets string +} + +func (f *mailAccountAddFlags) bind(cmd *cobra.Command) { + fl := cmd.Flags() + fl.StringVar(&f.password, "password", "", "initial mailbox password (required)") + fl.StringVar(&f.webmailAutologin, "webmail-autologin", "Y", "allow KAS-to-webmail auto-login (Y|N)") + fl.StringVar(&f.responder, "responder", "N", `auto-responder: "N", "Y", or a "|" timestamp range`) + fl.StringVar(&f.responderContentType, "responder-content-type", "text", "auto-responder body format (html|text)") + fl.StringVar(&f.responderDisplayName, "responder-displayname", "", "auto-responder sender display name") + fl.StringVar(&f.responderText, "responder-text", "", "auto-responder message body") + fl.StringVar(&f.copyAddress, "copy-address", "", "BCC copy recipient address(es)") + fl.StringVar(&f.senderAlias, "sender-alias", "", "permitted FROM alias address(es)") + fl.StringVar(&f.xlistEnabled, "xlist-enabled", "Y", "enable XLIST special-folder mapping (Y|N)") + fl.StringVar(&f.xlistSent, "xlist-sent", "Sent", "XLIST sent-items folder name") + fl.StringVar(&f.xlistDrafts, "xlist-drafts", "Drafts", "XLIST drafts folder name") + fl.StringVar(&f.xlistTrash, "xlist-trash", "Trash", "XLIST trash folder name") + fl.StringVar(&f.xlistSpam, "xlist-spam", "Spam", "XLIST spam folder name") + fl.StringVar(&f.xlistArchiv, "xlist-archiv", "Archive", "XLIST archive folder name") + fl.StringVar(&f.allowNets, "allow-nets", "", "restrict access to these IP/CIDR networks (empty = no restriction)") +} + +func (f *mailAccountAddFlags) spec(local, domain string) mailaccount.Spec { + return mailaccount.Spec{ + LocalPart: local, + DomainPart: domain, + Password: f.password, + WebmailAutologin: f.webmailAutologin, + Responder: f.responder, + ResponderContentType: f.responderContentType, + ResponderDisplayName: f.responderDisplayName, + ResponderText: f.responderText, + CopyAddress: f.copyAddress, + SenderAlias: f.senderAlias, + XListEnabled: f.xlistEnabled, + XListSent: f.xlistSent, + XListDrafts: f.xlistDrafts, + XListTrash: f.xlistTrash, + XListSpam: f.xlistSpam, + XListArchiv: f.xlistArchiv, + AllowNets: f.allowNets, + } +} + +func newMailAccountsAddCmd(opts *RootOptions) *cobra.Command { + f := &mailAccountAddFlags{} + cmd := &cobra.Command{ + Use: "add
--password [field flags]", + Short: "Create a mail account (add_mailaccount; the login is generated by KAS)", + Long: `Create a mail account via add_mailaccount. The address is split on the +last '@' into the local_part / domain_part KAS expects, and KAS +generates the mail login (e.g. m0000001), which the command prints on +success. + +The Y/N/text toggles and XLIST folder names default to the KAS API's +own defaults, so "accounts add info@example.com --password " is a +complete create; override any field with its flag.`, + Args: cobra.ExactArgs(1), + RunE: runWriteE(opts, func(args []string) (writeSpec, error) { + local, domain, err := splitMailAddress(args[0]) + if err != nil { + return writeSpec{}, err + } + if f.password == "" { + return writeSpec{}, fmt.Errorf("--password is required") + } + s := f.spec(local, domain) + return writeSpec{ + action: "add_mailaccount", + destructive: false, + confirm: ConfirmAction{Verb: "create", Resource: "mail account", ID: args[0]}, + params: mailaccount.AddParams(s), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + login, derr := mailaccount.NewClient(c).Add(ctx, s) + if derr != nil { + return "", derr + } + return "created mail account " + login, nil + }, + }, nil + }), + } + f.bind(cmd) + return cmd +} + +// mailAccountUpdateFlags binds the update_mailaccount mutable surface. +// Disjoint from mailAccountAddFlags: update has no local/domain (the +// account is addressed by its generated mail_login), adds the --active +// toggle (is_active), and its --password maps to mail_new_password (the +// update key) rather than the add-only mail_password. No flag carries a +// default — only the flags the user explicitly sets are sent, so an +// unset flag means "leave unchanged". +type mailAccountUpdateFlags struct { + password string + active string + webmailAutologin string + responder string + responderContentType string + responderDisplayName string + responderText string + copyAddress string + senderAlias string + xlistEnabled string + xlistSent string + xlistDrafts string + xlistTrash string + xlistSpam string + xlistArchiv string + allowNets string +} + +func (f *mailAccountUpdateFlags) bind(cmd *cobra.Command) { + fl := cmd.Flags() + fl.StringVar(&f.password, "password", "", "replacement mailbox password (sent as mail_new_password)") + fl.StringVar(&f.active, "active", "", "mailbox status (Y|N)") + fl.StringVar(&f.webmailAutologin, "webmail-autologin", "", "allow KAS-to-webmail auto-login (Y|N)") + fl.StringVar(&f.responder, "responder", "", `auto-responder: "N", "Y", or a "|" timestamp range`) + fl.StringVar(&f.responderContentType, "responder-content-type", "", "auto-responder body format (html|text)") + fl.StringVar(&f.responderDisplayName, "responder-displayname", "", "auto-responder sender display name") + fl.StringVar(&f.responderText, "responder-text", "", "auto-responder message body") + fl.StringVar(&f.copyAddress, "copy-address", "", "BCC copy recipient address(es)") + fl.StringVar(&f.senderAlias, "sender-alias", "", "permitted FROM alias address(es)") + fl.StringVar(&f.xlistEnabled, "xlist-enabled", "", "enable XLIST special-folder mapping (Y|N)") + fl.StringVar(&f.xlistSent, "xlist-sent", "", "XLIST sent-items folder name") + fl.StringVar(&f.xlistDrafts, "xlist-drafts", "", "XLIST drafts folder name") + fl.StringVar(&f.xlistTrash, "xlist-trash", "", "XLIST trash folder name") + fl.StringVar(&f.xlistSpam, "xlist-spam", "", "XLIST spam folder name") + fl.StringVar(&f.xlistArchiv, "xlist-archiv", "", "XLIST archive folder name") + fl.StringVar(&f.allowNets, "allow-nets", "", "restrict access to these IP/CIDR networks") +} + +// mailAccountUpdateChangedFields collects only the flags the user +// explicitly set into the update_mailaccount field map (keyed on the +// mailaccount.Field* constants). Each field is a wholesale replacement +// and an empty value is a meaningful set, so presence is keyed on cobra +// Changed, not on the value being non-empty — the same pattern the +// database/ftpuser updates use. The password flag maps to +// mail_new_password here (update_mailaccount's key). +func mailAccountUpdateChangedFields(cmd *cobra.Command, f *mailAccountUpdateFlags) map[string]string { + fields := map[string]string{} + for _, m := range []struct { + flag string + key string + value string + }{ + {"password", mailaccount.FieldNewPassword, f.password}, + {"active", mailaccount.FieldIsActive, f.active}, + {"webmail-autologin", mailaccount.FieldWebmailAutologin, f.webmailAutologin}, + {"responder", mailaccount.FieldResponder, f.responder}, + {"responder-content-type", mailaccount.FieldResponderContentType, f.responderContentType}, + {"responder-displayname", mailaccount.FieldResponderDisplayName, f.responderDisplayName}, + {"responder-text", mailaccount.FieldResponderText, f.responderText}, + {"copy-address", mailaccount.FieldCopyAddress, f.copyAddress}, + {"sender-alias", mailaccount.FieldSenderAlias, f.senderAlias}, + {"xlist-enabled", mailaccount.FieldXListEnabled, f.xlistEnabled}, + {"xlist-sent", mailaccount.FieldXListSent, f.xlistSent}, + {"xlist-drafts", mailaccount.FieldXListDrafts, f.xlistDrafts}, + {"xlist-trash", mailaccount.FieldXListTrash, f.xlistTrash}, + {"xlist-spam", mailaccount.FieldXListSpam, f.xlistSpam}, + {"xlist-archiv", mailaccount.FieldXListArchiv, f.xlistArchiv}, + {"allow-nets", mailaccount.FieldAllowNets, f.allowNets}, + } { + if cmd.Flags().Changed(m.flag) { + fields[m.key] = m.value + } + } + return fields +} + +func newMailAccountsUpdateCmd(opts *RootOptions) *cobra.Command { + f := &mailAccountUpdateFlags{} + cmd := &cobra.Command{ + Use: "update [field flags]", + Short: "Replace mutable fields of a mail account (update_mailaccount)", + Args: cobra.ExactArgs(1), + } + cmd.RunE = runWriteE(opts, func(args []string) (writeSpec, error) { + login := args[0] + fields := mailAccountUpdateChangedFields(cmd, f) + if len(fields) == 0 { + return writeSpec{}, fmt.Errorf("at least one field flag (e.g. --password/--active/--responder) is required") + } + return writeSpec{ + action: "update_mailaccount", + destructive: true, + confirm: ConfirmAction{Verb: "replace the settings of", Resource: "mail account", ID: login}, + params: mailaccount.UpdateParams(login, fields), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + if derr := mailaccount.NewClient(c).Update(ctx, login, fields); derr != nil { + return "", derr + } + return "updated mail account " + login, nil + }, + }, nil + }) + f.bind(cmd) + return cmd +} + +// mailAccountDeleteConfirm builds the ConfirmAction shown before +// delete_mailaccount is dispatched. Like delete_database it uses the +// louder "permanently delete" verb: deleting a mail account drops the +// mailbox and every message stored in it, so this is data loss, not +// just metadata removal. The prompt template adds "This cannot be +// undone." regardless of the verb. +func mailAccountDeleteConfirm(login string) ConfirmAction { + return ConfirmAction{Verb: "permanently delete", Resource: "mail account", ID: login} +} + +func newMailAccountsDeleteCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Permanently delete a mail account and all messages in it (delete_mailaccount)", + Args: cobra.ExactArgs(1), + RunE: runWriteE(opts, func(args []string) (writeSpec, error) { + login := args[0] + return writeSpec{ + action: "delete_mailaccount", + destructive: true, + confirm: mailAccountDeleteConfirm(login), + params: mailaccount.DeleteParams(login), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + if derr := mailaccount.NewClient(c).Delete(ctx, login); derr != nil { + return "", derr + } + return "deleted mail account " + login, nil + }, + }, nil + }), + } +} + func newMailForwardsCmd(opts *RootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "forwards", @@ -257,12 +519,14 @@ func newMailForwardsCmd(opts *RootOptions) *cobra.Command { } // splitMailAddress splits "local@domain" on the last '@' into the -// local_part / domain_part add_mailforward expects. get/update/delete -// take the full address verbatim; only add needs it decomposed. +// local_part / domain_part the add_mailforward and add_mailaccount +// actions expect. Their get/update/delete counterparts take the full +// address (forwards) or the generated mail_login (accounts) verbatim; +// only the add path needs the address decomposed. func splitMailAddress(addr string) (local, domain string, err error) { at := strings.LastIndex(addr, "@") if at <= 0 || at == len(addr)-1 { - return "", "", fmt.Errorf("invalid mail forward address %q: want local@domain", addr) + return "", "", fmt.Errorf("invalid mail address %q: want local@domain", addr) } return addr[:at], addr[at+1:], nil } diff --git a/internal/cli/mail_test.go b/internal/cli/mail_test.go index 8ce6630..861cc06 100644 --- a/internal/cli/mail_test.go +++ b/internal/cli/mail_test.go @@ -43,13 +43,231 @@ func TestMailAccountsHelpListsSubcommands(t *testing.T) { t.Fatalf("Execute: %v", err) } out := buf.String() - for _, want := range []string{"list", "get"} { + for _, want := range []string{"list", "get", "add", "update", "delete"} { if !strings.Contains(out, want) { t.Errorf("--help output missing %q\n%s", want, out) } } } +func TestMailAccountsAddRejectsBadInput(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + }{ + {"address without @", []string{"mail", "accounts", "add", "notanemail", "--password", "pw"}}, + {"missing --password", []string{"mail", "accounts", "add", "info@example.com"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(c.args) + err := root.Execute() + if err == nil { + t.Fatalf("Execute %v: want error, got nil", c.args) + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("exit code = %d, want ExitUserError", cli.CodeFor(err)) + } + }) + } +} + +// update_mailaccount with no field flags is rejected at build time +// (before any gating or credential resolution) with a user error — the +// API would otherwise fault nothing_to_do. Mirrors the add input- +// rejection test. +func TestMailAccountsUpdateRejectsNoFields(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"mail", "accounts", "update", "m0000001"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute: want error for update with no field flags, got nil") + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("exit code = %d, want ExitUserError", cli.CodeFor(err)) + } +} + +// The destructive accounts subcommands (update/delete) must refuse on a +// non-interactive stdin without --yes rather than dispatch unconfirmed. +func TestMailAccountsDestructiveRefuseNonTTY(t *testing.T) { + t.Parallel() + for _, args := range [][]string{ + {"mail", "accounts", "delete", "m0000001"}, + {"mail", "accounts", "update", "m0000001", "--active", "N"}, + } { + t.Run(args[2], func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetIn(strings.NewReader("")) + root.SetArgs(append(args, + "--login", "w0000000", "--auth-data", "x", "--auth-type", "plain")) + err := root.Execute() + if err == nil { + t.Fatalf("Execute %v: want refusal error, got nil", args) + } + if !errors.Is(err, cli.ErrConfirmationRequired) { + t.Errorf("err = %v, want ErrConfirmationRequired", err) + } + }) + } +} + +// delete_mailaccount uses the louder "permanently delete" verb (a +// deleted mailbox loses every stored message), shared only with +// delete_database. Pin the verb and the rendered Summary so neither a +// slice refactor nor a change to the shared ConfirmAction template can +// silently regress the loudness contract. +func TestMailAccountsDeleteConfirmIsLouder(t *testing.T) { + t.Parallel() + a := cli.MailAccountDeleteConfirm("m0000001") + if a.Verb != "permanently delete" { + t.Errorf("Verb = %q, want %q", a.Verb, "permanently delete") + } + if a.Resource != "mail account" { + t.Errorf("Resource = %q, want %q", a.Resource, "mail account") + } + want := `About to permanently delete mail account "m0000001". This cannot be undone.` + if got := a.Summary(); got != want { + t.Errorf("Summary() = %q, want %q", got, want) + } +} + +// add_mailaccount is non-destructive (no prompt) but still routes +// through the dry-run/audit seam. The dry-run preview must split the +// address into local_part/domain_part, send the add-only mail_password, +// carry the documented default toggles, and never leak the update-only +// mail_login / mail_new_password / is_active keys. +func TestMailAccountsAddDryRunParams(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetArgs([]string{ + "mail", "accounts", "add", "info@example.com", "--password", "s3cret", + "--dry-run", "-o", "json", + "--login", "w0", "--auth-data", "x", "--auth-type", "plain", + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + var got struct { + Action string `json:"action"` + Params map[string]string `json:"params"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal preview: %v\nstdout:%s", err, out.String()) + } + if got.Action != "add_mailaccount" { + t.Errorf("action = %q, want add_mailaccount", got.Action) + } + want := map[string]string{ + "local_part": "info", + "domain_part": "example.com", + // mail_password is a secret, so the audit/preview redacts its + // value — the key is still present but the value is masked. + "mail_password": "", + "webmail_autologin": "Y", + "responder": "N", + "mail_xlist_enabled": "Y", + "mail_xlist_sent": "Sent", + } + for k, v := range want { + if got.Params[k] != v { + t.Errorf("params[%q] = %q, want %q (full: %v)", k, got.Params[k], v, got.Params) + } + } + for _, k := range []string{"mail_login", "mail_new_password", "is_active"} { + if _, ok := got.Params[k]; ok { + t.Errorf("add params must not contain %q (full: %v)", k, got.Params) + } + } +} + +// The accounts update field assembly mirrors the lists update: only the +// flags the user explicitly set are sent (keyed on cobra Changed), the +// password maps to the update-only mail_new_password key, and --active +// maps to is_active. --dry-run renders the exact KAS params, so this +// asserts the assembly end to end without a network call. +func TestMailAccountsUpdateDryRunFieldAssembly(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want map[string]string + absent []string + }{ + { + "password maps to mail_new_password", + []string{"mail", "accounts", "update", "m0000001", "--password", "n3w"}, + // mail_new_password is a secret → redacted in the preview. + map[string]string{"mail_login": "m0000001", "mail_new_password": ""}, + []string{"mail_password", "is_active", "responder"}, + }, + { + "active and responder", + []string{"mail", "accounts", "update", "m0000001", "--active", "N", "--responder", "Y"}, + map[string]string{"mail_login": "m0000001", "is_active": "N", "responder": "Y"}, + []string{"mail_new_password"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + args := append(append([]string{}, c.args...), + "--dry-run", "-o", "json", + "--login", "w0", "--auth-data", "x", "--auth-type", "plain") + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute %v: %v", args, err) + } + var got struct { + Action string `json:"action"` + Params map[string]string `json:"params"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal preview: %v\nstdout:%s", err, out.String()) + } + if got.Action != "update_mailaccount" { + t.Errorf("action = %q, want update_mailaccount", got.Action) + } + for k, v := range c.want { + if got.Params[k] != v { + t.Errorf("params[%q] = %q, want %q (full: %v)", k, got.Params[k], v, got.Params) + } + } + for _, k := range c.absent { + if _, ok := got.Params[k]; ok { + t.Errorf("params[%q] present, want absent (full: %v)", k, got.Params) + } + } + }) + } +} + func TestMailForwardsHelpListsSubcommands(t *testing.T) { t.Parallel() root, opts := cli.NewRootCmd() diff --git a/internal/mailaccount/fault_test.go b/internal/mailaccount/fault_test.go index 2acc0fe..48eb876 100644 --- a/internal/mailaccount/fault_test.go +++ b/internal/mailaccount/fault_test.go @@ -6,10 +6,75 @@ import ( "github.com/chmmou/kasapi-cli/internal/testutil" ) +// TestFaultFixturesDecodeToDocumentedCodes pins every captured +// mailaccount fault fixture to the documented KAS fault code it carries. +// AssertFaultFixtures decodes each _response_failed_ file in the module +// dir to a *soap.FaultError (and checks the code is non-empty); listing +// every file here additionally asserts the exact code, so the fixtures +// stay the authoritative contract for the get/add/update/delete actions. func TestFaultFixturesDecodeToDocumentedCodes(t *testing.T) { t.Parallel() + //nolint:gosec // G101: these map values are KAS fault-code strings (e.g. "webmail_autologin_change_requires_new_password"), not hardcoded credentials. testutil.AssertFaultFixtures(t, "mailaccount", map[string]string{ - "add_mailaccount_response_failed_copy_adress_like_mailaccount.xml": "copy_adress_like_mailaccount", - "add_mailaccount_response_failed_copy_adress_syntax_incorrect.xml": "copy_adress_syntax_incorrect", + "add_mailaccount_response_failed_copy_adress_like_mailaccount.xml": "copy_adress_like_mailaccount", + "add_mailaccount_response_failed_copy_adress_syntax_incorrect.xml": "copy_adress_syntax_incorrect", + "add_mailaccount_response_failed_couldnt_get_kas_ressources.xml": "couldnt_get_kas_ressources", + "add_mailaccount_response_failed_email_already_exists.xml": "email_already_exists", + "add_mailaccount_response_failed_email_domain_doesnt_exist.xml": "email_domain_doesnt_exist", + "add_mailaccount_response_failed_email_syntax_incorrect.xml": "email_syntax_incorrect", + "add_mailaccount_response_failed_mail_loop_detected.xml": "mail_loop_detected", + "add_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml": "mail_xlist_archiv_syntax_incorrect", + "add_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml": "mail_xlist_drafts_syntax_incorrect", + "add_mailaccount_response_failed_mail_xlist_duplicate_folder.xml": "mail_xlist_duplicate_folder", + "add_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml": "mail_xlist_enabled_syntax_incorrect", + "add_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml": "mail_xlist_sent_syntax_incorrect", + "add_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml": "mail_xlist_spam_syntax_incorrect", + "add_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml": "mail_xlist_trash_syntax_incorrect", + "add_mailaccount_response_failed_max_emails_reached.xml": "max_emails_reached", + "add_mailaccount_response_failed_max_sender_alias_reached.xml": "max_sender_alias_reached", + "add_mailaccount_response_failed_missing_parameter.xml": "missing_parameter", + "add_mailaccount_response_failed_password_syntax_incorrect.xml": "password_syntax_incorrect", + "add_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml": "responder_contentype_syntax_incorrect", + "add_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml": "responder_displayname_syntax_incorrect", + "add_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml": "responder_not_allowed_for_catchall_adresses", + "add_mailaccount_response_failed_responder_startdate_gt_enddate.xml": "responder_startdate_gt_enddate", + "add_mailaccount_response_failed_responder_syntax_incorrect.xml": "responder_syntax_incorrect", + "add_mailaccount_response_failed_responder_text_is_empty.xml": "responder_text_is_empty", + "add_mailaccount_response_failed_sender_alias_domain_in_kas.xml": "sender_alias_domain_in_kas", + "add_mailaccount_response_failed_sender_alias_syntax_incorrect.xml": "sender_alias_syntax_incorrect", + "add_mailaccount_response_failed_webmail_autologin_syntax_incorrect.xml": "webmail_autologin_syntax_incorrect", + "delete_mailaccount_response_failed_in_progress.xml": "in_progress", + "delete_mailaccount_response_failed_mail_login_not_found.xml": "mail_login_not_found", + "delete_mailaccount_response_failed_mail_loop_detected.xml": "mail_loop_detected", + "delete_mailaccount_response_failed_missing_parameter.xml": "missing_parameter", + "update_mailaccount_response_failed_copy_adress_like_mailaccount.xml": "copy_adress_like_mailaccount", + "update_mailaccount_response_failed_copy_adress_syntax_incorrect.xml": "copy_adress_syntax_incorrect", + "update_mailaccount_response_failed_email_already_exists.xml": "email_already_exists", + "update_mailaccount_response_failed_email_domain_doesnt_exist.xml": "email_domain_doesnt_exist", + "update_mailaccount_response_failed_in_progress.xml": "in_progress", + "update_mailaccount_response_failed_is_active_syntax_incorrect.xml": "is_active_syntax_incorrect", + "update_mailaccount_response_failed_mail_login_not_found.xml": "mail_login_not_found", + "update_mailaccount_response_failed_mail_loop_detected.xml": "mail_loop_detected", + "update_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml": "mail_xlist_archiv_syntax_incorrect", + "update_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml": "mail_xlist_drafts_syntax_incorrect", + "update_mailaccount_response_failed_mail_xlist_duplicate_folder.xml": "mail_xlist_duplicate_folder", + "update_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml": "mail_xlist_enabled_syntax_incorrect", + "update_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml": "mail_xlist_sent_syntax_incorrect", + "update_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml": "mail_xlist_spam_syntax_incorrect", + "update_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml": "mail_xlist_trash_syntax_incorrect", + "update_mailaccount_response_failed_max_sender_alias_reached.xml": "max_sender_alias_reached", + "update_mailaccount_response_failed_missing_parameter.xml": "missing_parameter", + "update_mailaccount_response_failed_nothing_to_do.xml": "nothing_to_do", + "update_mailaccount_response_failed_password_syntax_incorrect.xml": "password_syntax_incorrect", + "update_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml": "responder_contentype_syntax_incorrect", + "update_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml": "responder_displayname_syntax_incorrect", + "update_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml": "responder_not_allowed_for_catchall_adresses", + "update_mailaccount_response_failed_responder_startdate_gt_enddate.xml": "responder_startdate_gt_enddate", + "update_mailaccount_response_failed_responder_syntax_incorrect.xml": "responder_syntax_incorrect", + "update_mailaccount_response_failed_responder_text_is_empty.xml": "responder_text_is_empty", + "update_mailaccount_response_failed_sender_alias_domain_in_kas.xml": "sender_alias_domain_in_kas", + "update_mailaccount_response_failed_sender_alias_syntax_incorrect.xml": "sender_alias_syntax_incorrect", + "update_mailaccount_response_failed_webmail_autologin_change_requires_new_password.xml": "webmail_autologin_change_requires_new_password", + "update_mailaccount_response_failed_webmail_autologin_syntax_incorrect.xml": "webmail_autologin_syntax_incorrect", }) } diff --git a/internal/mailaccount/mailaccount.go b/internal/mailaccount/mailaccount.go index 3167ca6..ef5a6e0 100644 --- a/internal/mailaccount/mailaccount.go +++ b/internal/mailaccount/mailaccount.go @@ -64,22 +64,30 @@ type MailAccount struct { // cli.Tabular. type MailAccountList []MailAccount -// Client groups the read endpoints scoped to mail accounts: -// get_mailaccounts (list and singular). +// Client groups the read endpoints scoped to mail accounts +// (get_mailaccounts, list and singular) and the write endpoints +// add_mailaccount / update_mailaccount / delete_mailaccount (see +// write.go). The raw Caller is kept alongside the read helper so the +// write methods can dispatch their own KAS actions through the shared +// kaswrite seam. type Client struct { lg kasread.ListGet[MailAccountList, MailAccount] + c Caller } // NewClient returns a Client backed by the given Caller. func NewClient(c Caller) *Client { - return &Client{lg: kasread.ListGet[MailAccountList, MailAccount]{ - Caller: c, - Action: "get_mailaccounts", - Label: "mailaccount", - ArgName: "login", - FilterKey: "mail_login", - Decoder: DecodeMailAccounts, - }} + return &Client{ + lg: kasread.ListGet[MailAccountList, MailAccount]{ + Caller: c, + Action: "get_mailaccounts", + Label: "mailaccount", + ArgName: "login", + FilterKey: "mail_login", + Decoder: DecodeMailAccounts, + }, + c: c, + } } // List calls get_mailaccounts without parameters and decodes the diff --git a/internal/mailaccount/mailaccount_test.go b/internal/mailaccount/mailaccount_test.go index eec8ef5..c389f4f 100644 --- a/internal/mailaccount/mailaccount_test.go +++ b/internal/mailaccount/mailaccount_test.go @@ -24,7 +24,7 @@ func TestDecodeMailAccounts(t *testing.T) { if a.Login != "m0000001" { t.Errorf("Login = %q, want m0000001", a.Login) } - if a.Addresses != "m0000001@example.com" || a.Adresses != "m0000001@example.com" { + if a.Addresses != "info@example.com" || a.Adresses != "info@example.com" { t.Errorf("addresses pair = %q / %q", a.Addresses, a.Adresses) } if a.Spamfilter != "pdw,sf" { @@ -52,11 +52,14 @@ func TestDecodeMailAccountSingular(t *testing.T) { t.Fatalf("len = %d, want 1", len(got)) } a := got[0] - if a.Login == "" { - t.Errorf("Login empty") + if a.Login != "m0000001" { + t.Errorf("Login = %q, want m0000001", a.Login) } - if a.UsedSpace == 0 { - t.Errorf("UsedSpace = 0, want non-zero") + // The singular fixture is an empty mailbox, so used_mailaccount_space + // is a real 0 (xsd:int) — assert the address decodes instead of + // pinning a non-zero usage that the fixture no longer carries. + if a.Addresses != "info@example.com" { + t.Errorf("Addresses = %q, want info@example.com", a.Addresses) } } diff --git a/internal/mailaccount/write.go b/internal/mailaccount/write.go new file mode 100644 index 0000000..04b6311 --- /dev/null +++ b/internal/mailaccount/write.go @@ -0,0 +1,194 @@ +package mailaccount + +import ( + "context" + "errors" + + "github.com/chmmou/kasapi-cli/internal/kaswrite" +) + +// ErrUnexpectedReturnString is the shared canonical post-call-contract +// sentinel, re-exported so errors.Is(err, mailaccount.ErrUnexpectedReturnString) +// keeps working and the slice stays self-describing. See +// kaswrite.ErrUnexpectedReturnString for the full contract. +var ErrUnexpectedReturnString = kaswrite.ErrUnexpectedReturnString + +const ( + addAction = "add_mailaccount" + updateAction = "update_mailaccount" + deleteAction = "delete_mailaccount" +) + +// The Field-prefixed constants are the KAS request keys +// update_mailaccount accepts besides the mail_login identifier; each is +// an optional wholesale replacement, so only the keys the caller +// explicitly sets are sent and an empty string is a meaningful value +// rather than "leave unchanged". +// +// Two asymmetries against the add path are baked into the wire keys and +// must not be conflated: +// +// - The password key differs by action: add_mailaccount sets the +// initial password via mail_password, update_mailaccount replaces it +// via mail_new_password (the same _new_password split as +// ftpuser/sambauser/database). FieldNewPassword is the update key; +// add uses its own mail_password key in AddParams. +// - FieldIsActive (is_active) and FieldLogin (mail_login) exist only on +// update: add_mailaccount takes no mail_login (the server generates +// it) and has no is_active toggle (a freshly created account is +// active). +// +// FieldCopyAddress keeps the legacy single-d "copy_adress" spelling +// because that is the literal wire key both add and update accept +// (mirrors the mail_adresses/mail_copy_adress legacy keys on the read +// path). +const ( + FieldLogin = "mail_login" + FieldNewPassword = "mail_new_password" + FieldWebmailAutologin = "webmail_autologin" + FieldResponder = "responder" + FieldResponderContentType = "mail_responder_content_type" + FieldResponderDisplayName = "mail_responder_displayname" + FieldResponderText = "responder_text" + FieldCopyAddress = "copy_adress" + FieldIsActive = "is_active" + FieldSenderAlias = "mail_sender_alias" + FieldXListEnabled = "mail_xlist_enabled" + FieldXListSent = "mail_xlist_sent" + FieldXListDrafts = "mail_xlist_drafts" + FieldXListTrash = "mail_xlist_trash" + FieldXListSpam = "mail_xlist_spam" + FieldXListArchiv = "mail_xlist_archiv" + FieldAllowNets = "mail_allow_nets" +) + +// Spec carries the add_mailaccount request fields. LocalPart, DomainPart +// and Password are required by the KAS API and validated as non-empty +// before any SOAP call so the CLI can surface a fast validation error; +// the remaining fields are optional and sent verbatim (an empty value is +// sent as an empty string, matching the captured add_mailaccount request +// fixture, which carries every key even when blank). +// +// add_mailaccount takes no mail_login: the server auto-generates the +// login and echoes it in ReturnInfo (e.g. "m0000001"), which Add +// returns. Responder is passed through verbatim — the API accepts the +// literal strings "N" / "Y" or a "|" timestamp range. +type Spec struct { + LocalPart string + DomainPart string + Password string + WebmailAutologin string + Responder string + ResponderContentType string + ResponderDisplayName string + ResponderText string + CopyAddress string + SenderAlias string + XListEnabled string + XListSent string + XListDrafts string + XListTrash string + XListSpam string + XListArchiv string + AllowNets string +} + +// Add creates a mail account (add_mailaccount) and returns the login the +// server assigns, as echoed in ReturnInfo (e.g. "m0000001"). +// +// LocalPart, DomainPart and Password are validated before the SOAP call +// so an obviously incomplete spec fails fast; the remaining syntax +// validation (email_syntax_incorrect, password_syntax_incorrect, +// mail_xlist_*_syntax_incorrect, responder_*, …) is left to the API and +// surfaces verbatim through the Caller. +func (cl *Client) Add(ctx context.Context, s Spec) (string, error) { + switch { + case s.LocalPart == "": + return "", errors.New("mailaccount: add_mailaccount requires a non-empty local part") + case s.DomainPart == "": + return "", errors.New("mailaccount: add_mailaccount requires a non-empty domain part") + case s.Password == "": + return "", errors.New("mailaccount: add_mailaccount requires a non-empty password") + } + resp, err := kaswrite.Call(ctx, cl.c, "mailaccount", addAction, AddParams(s)) + if err != nil { + return "", err + } + return resp.Body.ReturnInfo.AsString(), nil +} + +// AddParams builds the add_mailaccount KAS request parameter map. It is +// the single source of truth for the request shape so the CLI dry-run +// preview / audit record and the dispatched call cannot diverge. No +// mail_login is sent — the server generates it. The address is sent +// decomposed into local_part / domain_part (add_mailaccount's keys); +// every optional field is sent verbatim, matching the captured request +// fixture. +func AddParams(s Spec) map[string]any { + return map[string]any{ + "local_part": s.LocalPart, + "domain_part": s.DomainPart, + "mail_password": s.Password, + FieldWebmailAutologin: s.WebmailAutologin, + FieldResponder: s.Responder, + FieldResponderContentType: s.ResponderContentType, + FieldResponderDisplayName: s.ResponderDisplayName, + FieldResponderText: s.ResponderText, + FieldCopyAddress: s.CopyAddress, + FieldSenderAlias: s.SenderAlias, + FieldXListEnabled: s.XListEnabled, + FieldXListSent: s.XListSent, + FieldXListDrafts: s.XListDrafts, + FieldXListTrash: s.XListTrash, + FieldXListSpam: s.XListSpam, + FieldXListArchiv: s.XListArchiv, + FieldAllowNets: s.AllowNets, + } +} + +// Update changes one or more mutable fields of an existing mail account +// (update_mailaccount). fields holds only the keys the caller wants to +// change (use the Field* constants; the password is FieldNewPassword +// here, not the add-only mail_password); each is applied wholesale. +// login and at least one field are required — update_mailaccount with +// nothing to change is rejected before the SOAP call (the API would +// fault nothing_to_do). +func (cl *Client) Update(ctx context.Context, login string, fields map[string]string) error { + if login == "" { + return errors.New("mailaccount: update_mailaccount requires a non-empty mail login") + } + if len(fields) == 0 { + return errors.New("mailaccount: update_mailaccount requires at least one field to change") + } + _, err := kaswrite.Call(ctx, cl.c, "mailaccount", updateAction, UpdateParams(login, fields)) + return err +} + +// UpdateParams builds the update_mailaccount KAS request parameter map +// (single source of truth, see AddParams): the mail_login identifier +// plus every caller-supplied mutable field verbatim. +func UpdateParams(login string, fields map[string]string) map[string]any { + params := map[string]any{FieldLogin: login} + for k, v := range fields { + params[k] = v + } + return params +} + +// Delete removes a mail account (delete_mailaccount). A SOAP fault (e.g. +// mail_login_not_found, in_progress, mail_loop_detected) is surfaced +// verbatim by the Caller so the caller can classify it via the api error +// helpers. +func (cl *Client) Delete(ctx context.Context, login string) error { + if login == "" { + return errors.New("mailaccount: delete_mailaccount requires a non-empty mail login") + } + _, err := kaswrite.Call(ctx, cl.c, "mailaccount", deleteAction, DeleteParams(login)) + return err +} + +// DeleteParams builds the delete_mailaccount KAS request parameter map +// (single source of truth, see AddParams). +func DeleteParams(login string) map[string]any { + return map[string]any{FieldLogin: login} +} diff --git a/internal/mailaccount/write_test.go b/internal/mailaccount/write_test.go new file mode 100644 index 0000000..875af53 --- /dev/null +++ b/internal/mailaccount/write_test.go @@ -0,0 +1,215 @@ +package mailaccount_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/chmmou/kasapi-cli/internal/mailaccount" + "github.com/chmmou/kasapi-cli/internal/soap" + "github.com/chmmou/kasapi-cli/internal/testutil" +) + +func sampleSpec() mailaccount.Spec { + return mailaccount.Spec{ + LocalPart: "info", + DomainPart: "example.com", + Password: "s3cret", + WebmailAutologin: "Y", + Responder: "N", + ResponderContentType: "text", + ResponderDisplayName: "Info", + XListEnabled: "Y", + XListSent: "Sent", + XListDrafts: "Drafts", + XListTrash: "Trash", + XListSpam: "Spam", + XListArchiv: "Archive", + } +} + +func TestClientAdd(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "mailaccount/add_mailaccount_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + login, err := mailaccount.NewClient(fc).Add(context.Background(), sampleSpec()) + if err != nil { + t.Fatalf("Add: %v", err) + } + if fc.GotAction != "add_mailaccount" { + t.Errorf("action = %q, want add_mailaccount", fc.GotAction) + } + wantParams := map[string]any{ + "local_part": "info", + "domain_part": "example.com", + "mail_password": "s3cret", + "webmail_autologin": "Y", + "responder": "N", + "mail_responder_content_type": "text", + "mail_responder_displayname": "Info", + "mail_xlist_enabled": "Y", + "mail_xlist_sent": "Sent", + } + for k, v := range wantParams { + if fc.GotParams[k] != v { + t.Errorf("params[%q] = %v, want %v (full: %v)", k, fc.GotParams[k], v, fc.GotParams) + } + } + // add_mailaccount must not carry a mail_login (the server generates + // it), nor the update-only mail_new_password / is_active keys. + for _, k := range []string{"mail_login", "mail_new_password", "is_active"} { + if _, ok := fc.GotParams[k]; ok { + t.Errorf("add_mailaccount must not send %q: %v", k, fc.GotParams) + } + } + if login != "m0000001" { + t.Errorf("returned login = %q, want m0000001 (fixture ReturnInfo)", login) + } +} + +func TestClientUpdate(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "mailaccount/update_mailaccount_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + fields := map[string]string{ + mailaccount.FieldNewPassword: "n3wpass", + mailaccount.FieldIsActive: "Y", + mailaccount.FieldXListDrafts: "Entwürfe", + mailaccount.FieldResponder: "N", + } + if err := mailaccount.NewClient(fc).Update(context.Background(), "m0000001", fields); err != nil { + t.Fatalf("Update: %v", err) + } + if fc.GotAction != "update_mailaccount" { + t.Errorf("action = %q, want update_mailaccount", fc.GotAction) + } + wantParams := map[string]any{ + "mail_login": "m0000001", + "mail_new_password": "n3wpass", + "is_active": "Y", + "mail_xlist_drafts": "Entwürfe", + "responder": "N", + } + for k, v := range wantParams { + if fc.GotParams[k] != v { + t.Errorf("params[%q] = %v, want %v (full: %v)", k, fc.GotParams[k], v, fc.GotParams) + } + } + // update_mailaccount uses mail_new_password — the add-only + // mail_password key must not leak through. + if _, ok := fc.GotParams["mail_password"]; ok { + t.Errorf("update_mailaccount must not send mail_password (it uses mail_new_password): %v", fc.GotParams) + } +} + +func TestClientDelete(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "mailaccount/delete_mailaccount_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + if err := mailaccount.NewClient(fc).Delete(context.Background(), "m0000001"); err != nil { + t.Fatalf("Delete: %v", err) + } + if fc.GotAction != "delete_mailaccount" { + t.Errorf("action = %q, want delete_mailaccount", fc.GotAction) + } + if fc.GotParams["mail_login"] != "m0000001" { + t.Errorf("params = %v", fc.GotParams) + } +} + +func TestWriteValidation(t *testing.T) { + t.Parallel() + c := mailaccount.NewClient(&testutil.FakeCaller{}) + ctx := context.Background() + + // Each missing required add field surfaces a per-field validation + // error (mentioning only that single field), not a combined message + // — the latter forces the caller to guess which field broke. + for _, tc := range []struct { + name string + mut func(*mailaccount.Spec) + wantSub string + }{ + {"missing local part", func(s *mailaccount.Spec) { s.LocalPart = "" }, "local part"}, + {"missing domain part", func(s *mailaccount.Spec) { s.DomainPart = "" }, "domain part"}, + {"missing password", func(s *mailaccount.Spec) { s.Password = "" }, "password"}, + } { + s := sampleSpec() + tc.mut(&s) + if _, err := c.Add(ctx, s); err == nil { + t.Errorf("Add %s: err = nil, want validation error", tc.name) + } else if !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("Add %s: err = %q, want it to mention %q", tc.name, err.Error(), tc.wantSub) + } + } + if err := c.Update(ctx, "", map[string]string{mailaccount.FieldIsActive: "Y"}); err == nil { + t.Error("Update empty login: err = nil, want validation error") + } + if err := c.Update(ctx, "m0000001", nil); err == nil { + t.Error("Update no fields: err = nil, want validation error") + } + if err := c.Delete(ctx, ""); err == nil { + t.Error("Delete empty login: err = nil, want validation error") + } +} + +func TestUnexpectedReturnString(t *testing.T) { + t.Parallel() + resp := &soap.Response{Body: soap.ResponseBody{ReturnString: "FALSE"}} + c := mailaccount.NewClient(&testutil.FakeCaller{Resp: resp}) + ctx := context.Background() + if _, err := c.Add(ctx, sampleSpec()); !errors.Is(err, mailaccount.ErrUnexpectedReturnString) { + t.Errorf("Add err = %v, want ErrUnexpectedReturnString", err) + } + if err := c.Update(ctx, "m0000001", map[string]string{mailaccount.FieldIsActive: "Y"}); !errors.Is(err, mailaccount.ErrUnexpectedReturnString) { + t.Errorf("Update err = %v, want ErrUnexpectedReturnString", err) + } + if err := c.Delete(ctx, "m0000001"); !errors.Is(err, mailaccount.ErrUnexpectedReturnString) { + t.Errorf("Delete err = %v, want ErrUnexpectedReturnString", err) + } +} + +func TestWritePropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := mailaccount.NewClient(&testutil.FakeCaller{Err: want}) + ctx := context.Background() + if _, err := c.Add(ctx, sampleSpec()); !errors.Is(err, want) { + t.Errorf("Add err = %v, want %v", err, want) + } + if err := c.Update(ctx, "m0000001", map[string]string{mailaccount.FieldIsActive: "Y"}); !errors.Is(err, want) { + t.Errorf("Update err = %v, want %v", err, want) + } + if err := c.Delete(ctx, "m0000001"); !errors.Is(err, want) { + t.Errorf("Delete err = %v, want %v", err, want) + } +} + +func TestParamBuilders(t *testing.T) { + t.Parallel() + add := mailaccount.AddParams(sampleSpec()) + if add["local_part"] != "info" || add["domain_part"] != "example.com" || add["mail_password"] != "s3cret" { + t.Errorf("AddParams identity fields = %v", add) + } + // add must not contain the update-only keys. + for _, k := range []string{"mail_login", "mail_new_password", "is_active"} { + if _, ok := add[k]; ok { + t.Errorf("AddParams must not contain %q: %v", k, add) + } + } + upd := mailaccount.UpdateParams("m0000001", map[string]string{ + mailaccount.FieldNewPassword: "n3wpass", + mailaccount.FieldIsActive: "N", + }) + if upd["mail_login"] != "m0000001" || upd["mail_new_password"] != "n3wpass" || upd["is_active"] != "N" { + t.Errorf("UpdateParams = %v", upd) + } + if _, ok := upd["mail_password"]; ok { + t.Errorf("UpdateParams must not contain mail_password (update uses mail_new_password): %v", upd) + } + del := mailaccount.DeleteParams("m0000001") + if len(del) != 1 || del["mail_login"] != "m0000001" { + t.Errorf("DeleteParams = %v", del) + } +} diff --git a/testdata/mailaccount/add_mailaccount_request.xml b/testdata/mailaccount/add_mailaccount_request.xml index 9d61b0c..b5fecc9 100644 --- a/testdata/mailaccount/add_mailaccount_request.xml +++ b/testdata/mailaccount/add_mailaccount_request.xml @@ -5,16 +5,17 @@ { "KasRequestParams": { - "local_part": "info", - "domain_part": "example.com", "mail_password": "REDACTED", "webmail_autologin": "Y", + "local_part": "info", + "domain_part": "example.com", "responder": "N", "mail_responder_content_type": "text", "mail_responder_displayname": "Info", "responder_text": "", "copy_adress": "", "mail_sender_alias": "", + "mail_xlist_enabled": "Y", "mail_xlist_sent": "Sent", "mail_xlist_drafts": "Drafts", "mail_xlist_trash": "Trash", diff --git a/testdata/mailaccount/add_mailaccount_response_failed_couldnt_get_kas_ressources.xml b/testdata/mailaccount/add_mailaccount_response_failed_couldnt_get_kas_ressources.xml new file mode 100644 index 0000000..312c706 --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_couldnt_get_kas_ressources.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + couldnt_get_kas_ressources + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml new file mode 100644 index 0000000..cc065ac --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_archiv_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml new file mode 100644 index 0000000..6517490 --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_drafts_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_duplicate_folder.xml b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_duplicate_folder.xml new file mode 100644 index 0000000..a732e9a --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_duplicate_folder.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_duplicate_folder + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml new file mode 100644 index 0000000..0194a5c --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_enabled_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml new file mode 100644 index 0000000..0568ecd --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_sent_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml new file mode 100644 index 0000000..eddbfdc --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_spam_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml new file mode 100644 index 0000000..789bea5 --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_trash_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml new file mode 100644 index 0000000..dbcbe57 --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_contentype_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml b/testdata/mailaccount/add_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml new file mode 100644 index 0000000..eb4c672 --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_displayname_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml b/testdata/mailaccount/add_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml new file mode 100644 index 0000000..b635b85 --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_not_allowed_for_catchall_adresses + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_responder_startdate_gt_enddate.xml b/testdata/mailaccount/add_mailaccount_response_failed_responder_startdate_gt_enddate.xml new file mode 100644 index 0000000..5a30cf5 --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_responder_startdate_gt_enddate.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_startdate_gt_enddate + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_failed_sender_alias_domain_in_kas.xml b/testdata/mailaccount/add_mailaccount_response_failed_sender_alias_domain_in_kas.xml new file mode 100644 index 0000000..0aae5af --- /dev/null +++ b/testdata/mailaccount/add_mailaccount_response_failed_sender_alias_domain_in_kas.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + sender_alias_domain_in_kas + KasApi + + + + diff --git a/testdata/mailaccount/add_mailaccount_response_success.xml b/testdata/mailaccount/add_mailaccount_response_success.xml index a0e976f..964a8f7 100644 --- a/testdata/mailaccount/add_mailaccount_response_success.xml +++ b/testdata/mailaccount/add_mailaccount_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777736000 + 1779406589 KasRequestType @@ -17,6 +17,14 @@ KasRequestParams + + mail_password + REDACTED + + + webmail_autologin + Y + local_part info @@ -25,10 +33,6 @@ domain_part example.com - - webmail_autologin - Y - responder N @@ -41,6 +45,10 @@ mail_responder_displayname Info + + responder_text + + copy_adress @@ -49,6 +57,10 @@ mail_sender_alias + + mail_xlist_enabled + Y + mail_xlist_sent Sent @@ -102,7 +114,7 @@ text - The mail account has been created. The automatically generated login is: m0000001 + The email account info@example.com is going to be created. The username <i>m0000001</i> has been generated automatically. @@ -112,4 +124,4 @@ - + \ No newline at end of file diff --git a/testdata/mailaccount/delete_mailaccount_response_success.xml b/testdata/mailaccount/delete_mailaccount_response_success.xml index 4a674c2..60ce2f3 100644 --- a/testdata/mailaccount/delete_mailaccount_response_success.xml +++ b/testdata/mailaccount/delete_mailaccount_response_success.xml @@ -38,7 +38,7 @@ ReturnInfo - + m0000001 Msg @@ -50,7 +50,7 @@ text - The mail account was deleted. + The mail account has been deleted. @@ -60,4 +60,4 @@ - + \ No newline at end of file diff --git a/testdata/mailaccount/get_mailaccount_response_success.xml b/testdata/mailaccount/get_mailaccount_response_success.xml index f489f9c..855beba 100644 --- a/testdata/mailaccount/get_mailaccount_response_success.xml +++ b/testdata/mailaccount/get_mailaccount_response_success.xml @@ -50,11 +50,11 @@ mail_adresses - m0000001@example.com + info@example.com mail_addresses - m0000001@example.com + info@example.com mail_comment @@ -70,7 +70,7 @@ mail_responder_displayname - + Info mail_responder_content_type @@ -90,11 +90,11 @@ mail_spamfilter - pdw,sf + in_progress - FALSE + TRUE mail_xlist_enabled @@ -102,15 +102,15 @@ mail_xlist_sent - Gesendet + Sent mail_xlist_drafts - Entwürfe + Drafts mail_xlist_trash - Papierkorb + Trash mail_xlist_spam @@ -118,11 +118,11 @@ mail_xlist_archiv - Archiv + Archive used_mailaccount_space - 212.200195312 + 0 mail_is_active diff --git a/testdata/mailaccount/get_mailaccounts_response_success.xml b/testdata/mailaccount/get_mailaccounts_response_success.xml index 7361a23..b1c17a9 100644 --- a/testdata/mailaccount/get_mailaccounts_response_success.xml +++ b/testdata/mailaccount/get_mailaccounts_response_success.xml @@ -45,11 +45,11 @@ mail_adresses - m0000001@example.com + info@example.com mail_addresses - m0000001@example.com + info@example.com mail_comment diff --git a/testdata/mailaccount/update_mailaccount_request.xml b/testdata/mailaccount/update_mailaccount_request.xml index 466b917..3a4d5e6 100644 --- a/testdata/mailaccount/update_mailaccount_request.xml +++ b/testdata/mailaccount/update_mailaccount_request.xml @@ -9,10 +9,19 @@ "mail_new_password": "REDACTED", "webmail_autologin": "Y", "responder": "N", + "mail_responder_content_type": "html", + "mail_responder_displayname": "Info Update", "responder_text": "", "copy_adress": "", "is_active": "Y", - "mail_sender_alias": "" + "mail_sender_alias": "", + "mail_xlist_enabled": "Y", + "mail_xlist_sent": "gesendete Objekte", + "mail_xlist_drafts": "Entwürfe", + "mail_xlist_trash": "Papierkorb", + "mail_xlist_spam": "Spam", + "mail_xlist_archiv": "Archiv", + "mail_allow_nets": "" }, "kas_action": "update_mailaccount", "kas_auth_data": "REDACTED", diff --git a/testdata/mailaccount/update_mailaccount_response_failed_email_domain_doesnt_exist.xml b/testdata/mailaccount/update_mailaccount_response_failed_email_domain_doesnt_exist.xml new file mode 100644 index 0000000..ddf3ede --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_email_domain_doesnt_exist.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + email_domain_doesnt_exist + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_is_active_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_is_active_syntax_incorrect.xml new file mode 100644 index 0000000..bc1e7a3 --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_is_active_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + is_active_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml new file mode 100644 index 0000000..cc065ac --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_archiv_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_archiv_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml new file mode 100644 index 0000000..6517490 --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_drafts_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_drafts_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_duplicate_folder.xml b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_duplicate_folder.xml new file mode 100644 index 0000000..a732e9a --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_duplicate_folder.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_duplicate_folder + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml new file mode 100644 index 0000000..0194a5c --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_enabled_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_enabled_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml new file mode 100644 index 0000000..0568ecd --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_sent_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_sent_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml new file mode 100644 index 0000000..eddbfdc --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_spam_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_spam_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml new file mode 100644 index 0000000..789bea5 --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_mail_xlist_trash_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + mail_xlist_trash_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml new file mode 100644 index 0000000..dbcbe57 --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_responder_contentype_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_contentype_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml b/testdata/mailaccount/update_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml new file mode 100644 index 0000000..eb4c672 --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_responder_displayname_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_displayname_syntax_incorrect + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml b/testdata/mailaccount/update_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml new file mode 100644 index 0000000..b635b85 --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_responder_not_allowed_for_catchall_adresses.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_not_allowed_for_catchall_adresses + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_responder_startdate_gt_enddate.xml b/testdata/mailaccount/update_mailaccount_response_failed_responder_startdate_gt_enddate.xml new file mode 100644 index 0000000..5a30cf5 --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_responder_startdate_gt_enddate.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + responder_startdate_gt_enddate + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_failed_sender_alias_domain_in_kas.xml b/testdata/mailaccount/update_mailaccount_response_failed_sender_alias_domain_in_kas.xml new file mode 100644 index 0000000..0aae5af --- /dev/null +++ b/testdata/mailaccount/update_mailaccount_response_failed_sender_alias_domain_in_kas.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + sender_alias_domain_in_kas + KasApi + + + + diff --git a/testdata/mailaccount/update_mailaccount_response_success.xml b/testdata/mailaccount/update_mailaccount_response_success.xml index 33f413e..710e761 100644 --- a/testdata/mailaccount/update_mailaccount_response_success.xml +++ b/testdata/mailaccount/update_mailaccount_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777736120 + 1779410980 KasRequestType @@ -21,6 +21,10 @@ mail_login m0000001 + + mail_new_password + REDACTED + webmail_autologin Y @@ -29,6 +33,18 @@ responder N + + mail_responder_content_type + html + + + mail_responder_displayname + Info Update + + + responder_text + + copy_adress @@ -41,6 +57,34 @@ mail_sender_alias + + mail_xlist_enabled + Y + + + mail_xlist_sent + gesendete Objekte + + + mail_xlist_drafts + Entwürfe + + + mail_xlist_trash + Papierkorb + + + mail_xlist_spam + Spam + + + mail_xlist_archiv + Archiv + + + mail_allow_nets + + @@ -58,7 +102,7 @@ ReturnInfo - + m0000001 Msg @@ -70,7 +114,7 @@ text - The mail account m0000001 has been edited. + The mailbox of the address info@example.com (login m0000001) has been processed. @@ -80,4 +124,4 @@ - + \ No newline at end of file