Skip to content

Commit a2bb85e

Browse files
SeMmyTclaude
andcommitted
feat(admin): add email alias management commands
Add `zoh admin users aliases list|add|remove` for managing email aliases via the Zoho Mail Admin API. Update README with alias docs and mermaid architecture diagram. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6bad3a2 commit a2bb85e

6 files changed

Lines changed: 238 additions & 1 deletion

File tree

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ zoh send --to team@company.com --subject "Deploy done" --body "v2.1 is live"
1111
## Features
1212

1313
- **Full Zoho Mail API** — folders, labels, messages, search, threads, attachments, send/reply/forward
14-
- **Full Zoho Admin API** — users, groups, domains, audit logs, login history, SMTP logs
14+
- **Full Zoho Admin API** — users, groups, domains, email alias management, audit logs, login history, SMTP logs
1515
- **Mail administration** — spam filters, retention policies, delivery logs
1616
- **8 data centers**`us` `eu` `in` `au` `jp` `ca` `sa` `uk`
1717
- **Multiple output formats** — JSON (scriptable), plain (pipeable), rich (interactive tables)
@@ -20,6 +20,21 @@ zoh send --to team@company.com --subject "Deploy done" --body "v2.1 is live"
2020
- **Secure credential storage** — OS keyring or encrypted file (auto-detected for WSL/headless)
2121
- **Agent-friendly** — stable exit codes, `--results-only` JSON, `zoh schema` introspection
2222

23+
## Architecture
24+
25+
```mermaid
26+
graph LR
27+
CLI[zoh CLI] --> SP[Service Provider]
28+
SP --> AC[Admin Client]
29+
SP --> MC[Mail Client]
30+
AC --> API1[Zoho Mail Admin API]
31+
MC --> API2[Zoho Mail API]
32+
SP --> Auth[OAuth2 + Token Cache]
33+
Auth --> KR[OS Keyring / Encrypted File]
34+
API1 --> DC[8 Data Centers]
35+
API2 --> DC
36+
```
37+
2338
## Install
2439

2540
```bash
@@ -78,6 +93,11 @@ zoh admin domains list
7893
zoh admin domains add example.com
7994
zoh admin domains verify example.com --method txt
8095

96+
# Aliases
97+
zoh admin users aliases list user@example.com
98+
zoh admin users aliases add user@example.com alias1@example.com alias2@example.com
99+
zoh admin users aliases remove user@example.com old-alias@example.com --force
100+
81101
# Audit
82102
zoh admin audit logs --from 2025-01-01 --to 2025-01-31
83103
zoh admin audit login-history --from 2025-01-01 --to 2025-01-31 --mode failedLoginActivity

internal/cli/admin_aliases.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
"github.com/SeMmyT/zohcli/internal/output"
9+
)
10+
11+
// AdminAliasesListCmd lists aliases for a user
12+
type AdminAliasesListCmd struct {
13+
Identifier string `arg:"" help:"User ID (zuid) or email address"`
14+
}
15+
16+
// Run executes the list aliases command
17+
func (cmd *AdminAliasesListCmd) Run(sp *ServiceProvider, fp *FormatterProvider) error {
18+
adminClient, err := sp.Admin()
19+
if err != nil {
20+
return err
21+
}
22+
23+
ctx := context.Background()
24+
25+
_, user, err := resolveUserID(ctx, adminClient, cmd.Identifier)
26+
if err != nil {
27+
return &output.CLIError{
28+
Message: fmt.Sprintf("Failed to find user: %v", err),
29+
ExitCode: output.ExitAPIError,
30+
}
31+
}
32+
33+
type AliasDisplay struct {
34+
Email string `json:"email"`
35+
IsAlias bool `json:"isAlias"`
36+
IsPrimary bool `json:"isPrimary"`
37+
}
38+
39+
var aliases []AliasDisplay
40+
for _, ea := range user.EmailAddress {
41+
aliases = append(aliases, AliasDisplay{
42+
Email: ea.MailID,
43+
IsAlias: ea.IsAlias,
44+
IsPrimary: ea.IsPrimary,
45+
})
46+
}
47+
48+
columns := []output.Column{
49+
{Name: "Email", Key: "Email"},
50+
{Name: "Alias", Key: "IsAlias"},
51+
{Name: "Primary", Key: "IsPrimary"},
52+
}
53+
54+
return fp.Formatter.PrintList(aliases, columns)
55+
}
56+
57+
// AdminAliasesAddCmd adds an alias to a user
58+
type AdminAliasesAddCmd struct {
59+
Identifier string `arg:"" help:"User ID (zuid) or email address"`
60+
Aliases []string `arg:"" help:"Email alias(es) to add"`
61+
}
62+
63+
// Run executes the add alias command
64+
func (cmd *AdminAliasesAddCmd) Run(sp *ServiceProvider, fp *FormatterProvider, globals *Globals) error {
65+
if globals.DryRun {
66+
fmt.Fprintf(os.Stderr, "[DRY RUN] Would add aliases to %s: %v\n", cmd.Identifier, cmd.Aliases)
67+
return nil
68+
}
69+
70+
adminClient, err := sp.Admin()
71+
if err != nil {
72+
return err
73+
}
74+
75+
ctx := context.Background()
76+
77+
_, user, err := resolveUserID(ctx, adminClient, cmd.Identifier)
78+
if err != nil {
79+
return &output.CLIError{
80+
Message: fmt.Sprintf("Failed to find user: %v", err),
81+
ExitCode: output.ExitAPIError,
82+
}
83+
}
84+
85+
if err := adminClient.AddAlias(ctx, user.AccountID, cmd.Aliases); err != nil {
86+
return &output.CLIError{
87+
Message: fmt.Sprintf("Failed to add aliases: %v", err),
88+
ExitCode: output.ExitAPIError,
89+
}
90+
}
91+
92+
for _, alias := range cmd.Aliases {
93+
fmt.Fprintf(os.Stderr, "Alias added: %s -> %s\n", alias, user.PrimaryEmail())
94+
}
95+
96+
return nil
97+
}
98+
99+
// AdminAliasesRemoveCmd removes an alias from a user
100+
type AdminAliasesRemoveCmd struct {
101+
Identifier string `arg:"" help:"User ID (zuid) or email address"`
102+
Aliases []string `arg:"" help:"Email alias(es) to remove"`
103+
}
104+
105+
// Run executes the remove alias command
106+
func (cmd *AdminAliasesRemoveCmd) Run(sp *ServiceProvider, fp *FormatterProvider, globals *Globals) error {
107+
if globals.DryRun {
108+
fmt.Fprintf(os.Stderr, "[DRY RUN] Would remove aliases from %s: %v\n", cmd.Identifier, cmd.Aliases)
109+
return nil
110+
}
111+
112+
if !globals.Force {
113+
return &output.CLIError{
114+
Message: "Removing aliases requires --force flag",
115+
ExitCode: output.ExitUsage,
116+
}
117+
}
118+
119+
adminClient, err := sp.Admin()
120+
if err != nil {
121+
return err
122+
}
123+
124+
ctx := context.Background()
125+
126+
_, user, err := resolveUserID(ctx, adminClient, cmd.Identifier)
127+
if err != nil {
128+
return &output.CLIError{
129+
Message: fmt.Sprintf("Failed to find user: %v", err),
130+
ExitCode: output.ExitAPIError,
131+
}
132+
}
133+
134+
if err := adminClient.RemoveAlias(ctx, user.AccountID, cmd.Aliases); err != nil {
135+
return &output.CLIError{
136+
Message: fmt.Sprintf("Failed to remove aliases: %v", err),
137+
ExitCode: output.ExitAPIError,
138+
}
139+
}
140+
141+
for _, alias := range cmd.Aliases {
142+
fmt.Fprintf(os.Stderr, "Alias removed: %s from %s\n", alias, user.PrimaryEmail())
143+
}
144+
145+
return nil
146+
}

internal/cli/cli.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ type AdminUsersCmd struct {
119119
Activate AdminUsersActivateCmd `cmd:"" help:"Activate a user account"`
120120
Deactivate AdminUsersDeactivateCmd `cmd:"" help:"Deactivate a user account"`
121121
Delete AdminUsersDeleteCmd `cmd:"" help:"Delete a user permanently"`
122+
Aliases AdminAliasesCmd `cmd:"" help:"Manage user email aliases"`
123+
}
124+
125+
// AdminAliasesCmd holds alias subcommands
126+
type AdminAliasesCmd struct {
127+
List AdminAliasesListCmd `cmd:"" help:"List user email aliases"`
128+
Add AdminAliasesAddCmd `cmd:"" help:"Add email alias(es) to a user"`
129+
Remove AdminAliasesRemoveCmd `cmd:"" help:"Remove email alias(es) from a user"`
122130
}
123131

124132
// AdminGroupsCmd holds group subcommands

internal/zoho/admin_client.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,60 @@ func (ac *AdminClient) RemoveGroupMembers(ctx context.Context, zgid int64, membe
576576
return nil
577577
}
578578

579+
// AddAlias adds email aliases to a user account
580+
func (ac *AdminClient) AddAlias(ctx context.Context, accountID string, aliases []string) error {
581+
path := fmt.Sprintf("/api/organization/%d/accounts/%s", ac.zoid, accountID)
582+
583+
req := AliasRequest{
584+
Mode: "addEmailAlias",
585+
EmailAlias: aliases,
586+
}
587+
588+
body, err := json.Marshal(req)
589+
if err != nil {
590+
return fmt.Errorf("marshal request: %w", err)
591+
}
592+
593+
resp, err := ac.client.DoMail(ctx, http.MethodPut, path, bytes.NewReader(body))
594+
if err != nil {
595+
return fmt.Errorf("request failed: %w", err)
596+
}
597+
defer resp.Body.Close()
598+
599+
if resp.StatusCode != http.StatusOK {
600+
return ac.parseErrorResponse(resp)
601+
}
602+
603+
return nil
604+
}
605+
606+
// RemoveAlias removes email aliases from a user account
607+
func (ac *AdminClient) RemoveAlias(ctx context.Context, accountID string, aliases []string) error {
608+
path := fmt.Sprintf("/api/organization/%d/accounts/%s", ac.zoid, accountID)
609+
610+
req := AliasRequest{
611+
Mode: "removeEmailAlias",
612+
EmailAlias: aliases,
613+
}
614+
615+
body, err := json.Marshal(req)
616+
if err != nil {
617+
return fmt.Errorf("marshal request: %w", err)
618+
}
619+
620+
resp, err := ac.client.DoMail(ctx, http.MethodPut, path, bytes.NewReader(body))
621+
if err != nil {
622+
return fmt.Errorf("request failed: %w", err)
623+
}
624+
defer resp.Body.Close()
625+
626+
if resp.StatusCode != http.StatusOK {
627+
return ac.parseErrorResponse(resp)
628+
}
629+
630+
return nil
631+
}
632+
579633
// ListDomains fetches all domains in the organization
580634
func (ac *AdminClient) ListDomains(ctx context.Context) ([]Domain, error) {
581635
path := fmt.Sprintf("/api/organization/%d/domains", ac.zoid)

internal/zoho/admin_service.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type AdminService interface {
3737
GetLoginHistory(ctx context.Context, mode string, startTime, endTime time.Time, batchSize int) ([]LoginHistoryEntry, error)
3838
GetSMTPLogs(ctx context.Context, startTime, endTime time.Time, searchCriteria, searchKey string, limit int) ([]SMTPLogEntry, error)
3939

40+
AddAlias(ctx context.Context, accountID string, aliases []string) error
41+
RemoveAlias(ctx context.Context, accountID string, aliases []string) error
42+
4043
GetAntispamOption(ctx context.Context, check string) (string, error)
4144
SetAntispamOption(ctx context.Context, check, option string) error
4245
}

internal/zoho/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,12 @@ type SMTPLogResponse struct {
332332
} `json:"data"`
333333
}
334334

335+
// AliasRequest is the request body for adding/removing email aliases
336+
type AliasRequest struct {
337+
Mode string `json:"mode"` // "addEmailAlias" or "removeEmailAlias"
338+
EmailAlias []string `json:"emailAlias"`
339+
}
340+
335341
// APIError represents an error response from the Zoho API
336342
type APIError struct {
337343
Status struct {

0 commit comments

Comments
 (0)