Skill: panthrocorp/panthrocorp-zoho-mail (version 0.3.1)

--- name: Zoho Mail description: Full read/write Zoho Mail access for OpenClaw agents version: 0.3.0 author: panthrocorp license: MIT-0 metadata: openclaw: requires: env: - ZOHO_MAIL_TOKEN_KEY - ZOHO_CLIENT_ID - ZOHO_CLIENT_SECRET bins: - zoho-mail emoji: "📬" homepage: https://github.com/PanthroCorp-Limited/openclaw-skills os: ["linux"] --- # Zoho Mail Skill Full read/write access to a Zoho Mail account (EU data centre). ## Installation Download the latest release binary for linux/arm64 and install to `~/.openclaw/bin/`: ```bash TAG=$(curl -fsSL "https://api.github.com/repos/PanthroCorp-Limited/openclaw-skills/releases" \ | grep -o '"tag_name":"zoho-mail/v[^"]*"' | head -1 | cut -d'"' -f4) VERSION=${TAG#zoho-mail/v} curl -fsSL "https://github.com/PanthroCorp-Limited/openclaw-skills/releases/download/${TAG}/zoho-mail_${VERSION}_linux_arm64.tar.gz" \ | tar -xz -C ~/.openclaw/bin/ zoho-mail chmod +x ~/.openclaw/bin/zoho-mail ``` ## Important OAuth tokens are encrypted at rest. The operator must run `zoho-mail auth login` once after deployment to complete the OAuth flow. ## Check configuration ``` zoho-mail config show ``` ## Mail commands List messages in a folder (default: INBOX): ``` zoho-mail mail list zoho-mail mail list --folder INBOX --limit 20 --start 0 ``` Read a message by ID: ``` zoho-mail mail read --id MESSAGE_ID ``` Send a new message: ``` zoho-mail mail send --to [REDACTED_SECRET] --subject "Hello" --body "Message body" zoho-mail mail send --to [REDACTED_SECRET] --subject "Hello" --body "<p>HTML body</p>" --html zoho-mail mail send --to [REDACTED_SECRET] --cc [REDACTED_SECRET] --subject "Hello" --body "Body" ``` Reply to a message: ``` zoho-mail mail reply --id MESSAGE_ID --body "Reply text" zoho-mail mail reply --id MESSAGE_ID --body "<p>HTML reply</p>" --html ``` Search messages: ``` zoho-mail mail search --query "from:[REDACTED_SECRET]" zoho-mail mail search --query "subject:invoice" --limit 20 ``` Delete a message: ``` zoho-mail mail delete --id MESSAGE_ID --folder FOLDER_ID ``` Mark messages as read or unread: ``` zoho-mail mail mark --ids id1,id2,id3 --read zoho-mail mail mark --ids id1 --unread ``` ## Folder commands List all folders: ``` zoho-mail folders list ``` ## Authentication commands Complete the OAuth flow (interactive, run by operator): ``` zoho-mail auth login ``` Check authentication status: ``` zoho-mail auth status ``` Delete the stored token: ``` zoho-mail auth revoke ``` ## Output format All commands output JSON by default. ## Setup (operator, one-time per instance) 1. Set the account email address: ``` zoho-mail config set --email [REDACTED_SECRET] ``` 2. Authenticate: ``` zoho-mail auth login ``` Visit the printed URL, authenticate, then paste the full redirect URL back into the terminal.

path: CHANGELOG.md
content: # Changelog

## [0.3.1](https://github.com/PanthroCorp-Limited/openclaw-skills/compare/zoho-mail/v0.3.0...zoho-mail/v0.3.1) (2026-04-08)


### Bug Fixes

* pass version explicitly to clawhub ([#41](https://github.com/PanthroCorp-Limited/openclaw-skills/issues/41)) ([64d39ac](https://github.com/PanthroCorp-Limited/openclaw-skills/commit/64d39ac32023e85de75ea91b50d029dab53c7acb))
* quote SKILL.md versions for clawhub ([#37](https://github.com/PanthroCorp-Limited/openclaw-skills/issues/37)) ([38f9dc3](https://github.com/PanthroCorp-Limited/openclaw-skills/commit/38f9dc3f1670782eb5ebc5ce511117bd96ad4497))
* sync SKILL.md versions with release-please ([#33](https://github.com/PanthroCorp-Limited/openclaw-skills/issues/33)) ([bbec105](https://github.com/PanthroCorp-Limited/openclaw-skills/commit/bbec105b505b757ae9af6d61c507af37cdd3941d))

## [0.3.0](https://github.com/PanthroCorp-Limited/openclaw-skills/compare/zoho-mail/v0.2.1...zoho-mail/v0.3.0) (2026-04-02)


### Features

* add install instructions to SKILL.md ([#27](https://github.com/PanthroCorp-Limited/openclaw-skills/issues/27)) ([b636869](https://github.com/PanthroCorp-Limited/openclaw-skills/commit/b63686914db2f0efbff75f9a5455da79adfe8678))

## [0.2.1](https://github.com/PanthroCorp-Limited/openclaw-skills/compare/zoho-mail/v0.2.0...zoho-mail/v0.2.1) (2026-03-30)


### Bug Fixes

* update GitHub org to PanthroCorp-Limited ([#22](https://github.com/PanthroCorp-Limited/openclaw-skills/issues/22)) ([7b745b4](https://github.com/PanthroCorp-Limited/openclaw-skills/commit/7b745b4e1950bac3afd7595a78bdf0e116919ff0))

## [0.2.0](https://github.com/PanthroCorp-Limited/openclaw-skills/compare/zoho-mail/v0.1.0...zoho-mail/v0.2.0) (2026-03-29)


### Features

* add zoho-mail skill and fix google-workspace OAuth docs ([#18](https://github.com/PanthroCorp-Limited/openclaw-skills/issues/18)) ([5373fdf](https://github.com/PanthroCorp-Limited/openclaw-skills/commit/5373fdfaafbc04888b1825a7d94af789e94f9344))


path: CLAUDE.md
content: # zoho-mail

## Overview

Full read/write Zoho Mail skill for OpenClaw agents. Unlike the google-workspace skill, this has no read-only restriction: it can list, read, send, reply, delete, and mark messages.

## Architecture

- `cmd/` — Cobra CLI. Subcommand groups `mail`, `folders`, `auth`, and `config` each register on `rootCmd`. `zohoClient()` in `cmd/mail.go` centralises config loading, token decryption, and API client construction.
- `internal/zoho/client.go` — Thin HTTP wrapper over the Zoho Mail REST API (`https://mail.zoho.eu/api`). Account ID is discovered on first API call via `GET /accounts` and cached in `Client.accountID`. All message and folder operations path-encode this ID.
- `internal/oauth/oauth.go` — OAuth2 flow with a localhost redirect (`http://localhost:8080/callback`). `InteractiveLogin` asks the operator to paste the **full redirect URL** back into the terminal (e.g. `http://localhost:8080/callback?code=...`). The code is then extracted from the URL. This differs from google-workspace, which asks for the code value only.
- `internal/crypto/` — AES-256-GCM with HKDF-SHA256 key derivation. Wire format: `salt (16B) || nonce (12B) || ciphertext+tag`. Same pattern as google-workspace.
- `persistingTokenSource` in `cmd/mail.go` — wraps the OAuth2 token source and saves a refreshed token back to disk whenever the access token changes. This keeps the stored token current without manual re-authentication.

## Key differences from google-workspace

- Full write access. No service modes and no config-level gating.
- OAuth redirect URL is `http://localhost:8080/callback`. The operator pastes the **full redirect URL** (not just the code).
- EU data centre only: all API and auth endpoints use `zoho.eu` domains.
- Config is minimal: only `email` field (`config.json`). No scope selection logic.

## Required environment variables

| Variable | Purpose |
|----------|---------|
| `ZOHO_MAIL_TOKEN_KEY` | Passphrase for AES-256-GCM token encryption |
| `ZOHO_CLIENT_ID` | Zoho API Console client ID |
| `ZOHO_CLIENT_SECRET` | Zoho API Console client secret |

Config directory defaults to `~/.openclaw/credentials/zoho-mail/`. Override with `ZOHO_MAIL_CONFIG_DIR` or `--config-dir`.

## OAuth scopes

`ZohoMail.messages.ALL`, `ZohoMail.folders.ALL`, `ZohoMail.accounts.READ` — defined in `internal/oauth/oauth.go`.


path: README.md
content: # zoho-mail

![Language](https://img.shields.io/badge/language-Go-00ADD8?logo=go&logoColor=white)
![License](https://img.shields.io/badge/license-MIT--0-green)
![Platform](https://img.shields.io/badge/platform-linux%2Farm64-lightgrey)

A custom OpenClaw skill providing full read/write access to a Zoho Mail account (EU data centre).

## Services

| Service | Scopes | Access |
|---------|--------|--------|
| Mail | `ZohoMail.messages.ALL` | List, read, send, reply, delete, mark read/unread |
| Folders | `ZohoMail.folders.ALL` | List |
| Accounts | `ZohoMail.accounts.READ` | Account ID discovery |

## Installation

```bash
clawhub install panthrocorp-zoho-mail
```

Or build from source:

```bash
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o zoho-mail .
```

## Deployment guide

Follow these steps in order to deploy the skill to an OpenClaw instance.

### 1. Create a Zoho API Console application

1. Go to the [Zoho API Console](https://api-console.zoho.eu/) (EU region)
2. Create a **Server-based Application**
3. Set the redirect URL to `http://localhost:8080/callback`
4. Note the **Client ID** and **Client Secret**

### 2. Create secrets in Bitwarden Secrets Manager

Create three secrets in Bitwarden SM (EU region, PanthroCorp project):

| Secret key | Value |
|------------|-------|
| `ZOHO_MAIL_TOKEN_KEY_OPENCLAW_AWS_SANDBOX_<INSTANCE>` | Random 64-character hex string (`openssl rand -hex 32`) |
| `ZOHO_CLIENT_ID_OPENCLAW_AWS_SANDBOX_<INSTANCE>` | Client ID from step 1 |
| `ZOHO_CLIENT_SECRET_OPENCLAW_AWS_SANDBOX_<INSTANCE>` | Client secret from step 1 |

### 3. Add Terraform configuration

In the `openclaw` repo, add the Bitwarden secret key references to the instance's tfvars file:

```hcl
bitwarden_secret_key_zoho_mail_token_key = "ZOHO_MAIL_TOKEN_KEY_OPENCLAW_AWS_SANDBOX_<INSTANCE>"  # pragma: allowlist secret
bitwarden_secret_key_zoho_client_id      = "ZOHO_CLIENT_ID_OPENCLAW_AWS_SANDBOX_<INSTANCE>"  # pragma: allowlist secret
bitwarden_secret_key_zoho_client_secret  = "ZOHO_CLIENT_SECRET_OPENCLAW_AWS_SANDBOX_<INSTANCE>"  # pragma: allowlist secret
```

### 4. Deploy

Merge the Terraform changes to `main`. The deploy workflow will:
- Create SSM parameters for the three secrets
- Update the `.env` file on the instance with `ZOHO_MAIL_TOKEN_KEY`, `ZOHO_CLIENT_ID`, and `ZOHO_CLIENT_SECRET`
- Create the `config/credentials/zoho-mail/` directory

### 5. Install the skill on the instance

SSH in via Twingate and install:

```bash
ssh -i ~/.ssh/openclaw-operator ubuntu@<domain>
sudo -u openclaw docker exec -it openclaw-gateway clawhub install panthrocorp-zoho-mail
```

### 6. Set the account email

```bash
sudo -u openclaw docker exec -it openclaw-gateway \
  zoho-mail config set --email [REDACTED_SECRET]
```

### 7. Authenticate with Zoho

```bash
sudo -u openclaw docker exec -it openclaw-gateway \
  zoho-mail auth login
```

1. Copy the URL printed to the terminal
2. Open it in your local browser and authenticate with your Zoho account
3. After authorisation, copy the full redirect URL from your browser's address bar (it will start with `http://localhost:8080/callback?code=...`)
4. Paste the full URL back into the terminal

### 8. Verify

```bash
sudo -u openclaw docker exec -it openclaw-gateway zoho-mail auth status
sudo -u openclaw docker exec -it openclaw-gateway zoho-mail folders list
```

No container restart is needed. The token is persisted on the host volume and the binary reads it fresh on each invocation.

## Prerequisites

- A Zoho API Console application (EU region) with redirect URL `http://localhost:8080/callback`
- Three environment variables on the host:
  - `ZOHO_CLIENT_ID` and `ZOHO_CLIENT_SECRET` from the API Console application
  - `ZOHO_MAIL_TOKEN_KEY` for encrypting the stored OAuth token (random 64-char hex string)

## Usage

### Configure

```bash
zoho-mail config set --email [REDACTED_SECRET]
zoho-mail config show
```

### Authenticate

```bash
zoho-mail auth login
```

This prints a URL. Open it in your browser, authorise the requested scopes, then paste the full redirect URL back into the terminal.

### Mail

```bash
# List inbox
zoho-mail mail list
zoho-mail mail list --folder FOLDER_ID --limit 20 --start 0

# Read a message
zoho-mail mail read --id MESSAGE_ID

# Send
zoho-mail mail send --to [REDACTED_SECRET] --subject "Hello" --body "Message body"
zoho-mail mail send --to [REDACTED_SECRET] --subject "Hello" --body "<p>HTML body</p>" --html
zoho-mail mail send --to [REDACTED_SECRET] --cc [REDACTED_SECRET] --subject "Hello" --body "Body"

# Reply
zoho-mail mail reply --id MESSAGE_ID --body "Reply text"
zoho-mail mail reply --id MESSAGE_ID --body "<p>HTML reply</p>" --html

# Search
zoho-mail mail search --query "from:[REDACTED_SECRET]"
zoho-mail mail search --query "subject:invoice" --limit 20

# Delete
zoho-mail mail delete --id MESSAGE_ID --folder FOLDER_ID

# Mark read/unread
zoho-mail mail mark --ids id1,id2,id3 --read
zoho-mail mail mark --ids id1 --unread
```

### Folders

```bash
zoho-mail folders list
```

### Check auth status

```bash
zoho-mail auth status
```

## Token storage

OAuth tokens are encrypted at rest using AES-256-GCM with a key derived via HKDF-SHA256. The encryption key comes from `ZOHO_MAIL_TOKEN_KEY`, injected from an external secret manager. The binary refuses to store tokens if this variable is not set.

Default token location: `~/.openclaw/credentials/zoho-mail/token.enc`

Refreshed tokens are automatically written back to disk by the `persistingTokenSource` wrapper on each token refresh, so the stored token stays current across invocations without requiring manual re-authentication.

## Security

- Token encryption uses a random salt per operation, preventing identical tokens from producing identical ciphertext.
- The binary exits immediately if any required environment variable is absent.
- Running `zoho-mail auth revoke` removes the local token only. To fully revoke access, also visit [Zoho account sessions](https://accounts.zoho.eu/home#sessions).

## Development

```bash
go build -o zoho-mail .
go test ./...
go vet ./...
```

## License

[MIT No Attribution](./LICENSE)


path: cmd/auth.go
content: package cmd

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/PanthroCorp-Limited/openclaw-skills/zoho-mail/internal/oauth"
	"github.com/spf13/cobra"
)

var authCmd = &cobra.Command{
	Use:   "auth",
	Short: "Manage Zoho OAuth authentication",
}

var authLoginCmd = &cobra.Command{
	Use:   "login",
	Short: "Authenticate with Zoho (interactive OAuth flow)",
	Run: func(cmd *cobra.Command, args []string) {
		key := encryptionKey()
		if key == "" {
			exitf("ZOHO_MAIL_TOKEN_KEY environment variable is not set")
		}

		cID := clientID()
		cSecret := clientSecret()
		if cID == "" || cSecret == "" {
			exitf("ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET environment variables must be set")
		}

		oauthCfg := oauth.NewOAuthConfig(cID, cSecret)
		ctx := context.Background()

		fmt.Printf("Requesting scopes: %v\n\n", oauth.Scopes)

		token, err := oauth.InteractiveLogin(ctx, oauthCfg)
		if err != nil {
			exitf("authentication failed: %v", err)
		}

		if err := oauth.SaveToken(configDir, token, key); err != nil {
			exitf("saving token: %v", err)
		}

		fmt.Println("\nAuthentication successful. Token encrypted and stored.")
	},
}

var authStatusCmd = &cobra.Command{
	Use:   "status",
	Short: "Show current authentication status",
	Run: func(cmd *cobra.Command, args []string) {
		key := encryptionKey()
		if key == "" {
			exitf("ZOHO_MAIL_TOKEN_KEY environment variable is not set")
		}

		if !oauth.TokenExists(configDir) {
			fmt.Println("No token found. Run 'zoho-mail auth login' to authenticate.")
			return
		}

		token, err := oauth.LoadToken(configDir, key)
		if err != nil {
			exitf("loading token: %v", err)
		}

		status := map[string]any{
			"authenticated": true,
			"token_type":    token.TokenType,
			"has_refresh":   token.RefreshToken != "",
			"expiry":        token.Expiry.Format(time.RFC3339),
			"expired":       token.Expiry.Before(time.Now()),
		}

		data, _ := json.MarshalIndent(status, "", "  ")
		fmt.Println(string(data))
	},
}

var authRevokeCmd = &cobra.Command{
	Use:   "revoke",
	Short: "Delete the stored OAuth token",
	Run: func(cmd *cobra.Command, args []string) {
		if err := oauth.DeleteToken(configDir); err != nil {
			exitf("revoking token: %v", err)
		}
		fmt.Println("Local token deleted. To fully revoke access, visit https://accounts.zoho.eu/home#sessions")
	},
}

func init() {
	authCmd.AddCommand(authLoginCmd, authStatusCmd, authRevokeCmd)
	rootCmd.AddCommand(authCmd)
}


path: cmd/config.go
content: package cmd

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/spf13/cobra"
)

const configFileName = "config.json"

// mailConfig holds the persisted skill configuration.
type mailConfig struct {
	Email string `json:"email"`
}

func loadConfig(dir string) (mailConfig, error) {
	path := filepath.Join(dir, configFileName)
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return mailConfig{}, nil
		}
		return mailConfig{}, fmt.Errorf("reading config: %w", err)
	}

	var cfg mailConfig
	if err := json.Unmarshal(data, &cfg); err != nil {
		return mailConfig{}, fmt.Errorf("parsing config: %w", err)
	}

	return cfg, nil
}

func saveConfig(dir string, cfg mailConfig) error {
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("creating config directory: %w", err)
	}

	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return fmt.Errorf("marshalling config: %w", err)
	}

	data = append(data, '\n')
	path := filepath.Join(dir, configFileName)
	return os.WriteFile(path, data, 0o600)
}

var configCmd = &cobra.Command{
	Use:   "config",
	Short: "Manage skill configuration",
}

var configShowCmd = &cobra.Command{
	Use:   "show",
	Short: "Display current configuration",
	Run: func(cmd *cobra.Command, args []string) {
		cfg, err := loadConfig(configDir)
		if err != nil {
			exitf("loading config: %v", err)
		}
		printJSON(cfg)
	},
}

var setEmail string

var configSetCmd = &cobra.Command{
	Use:   "set",
	Short: "Update skill configuration",
	Long:  "Set the email address for the Zoho Mail account. Re-run 'zoho-mail auth login' after changing this value.",
	Run: func(cmd *cobra.Command, args []string) {
		cfg, err := loadConfig(configDir)
		if err != nil {
			exitf("loading config: %v", err)
		}

		if cmd.Flags().Changed("email") {
			cfg.Email = setEmail
		}

		if cfg.Email == "" {
			exitf("--email is required")
		}

		if err := saveConfig(configDir, cfg); err != nil {
			exitf("saving config: %v", err)
		}

		printJSON(cfg)
		fmt.Println("\nConfig saved. Run 'zoho-mail auth login' if this is a new account.")
	},
}

func init() {
	configSetCmd.Flags().StringVar(&setEmail, "email", "", "Zoho Mail email address for this account")

	configCmd.AddCommand(configShowCmd, configSetCmd)
	rootCmd.AddCommand(configCmd)
}


path: cmd/folders.go
content: package cmd

import (
	"github.com/spf13/cobra"
)

var foldersCmd = &cobra.Command{
	Use:   "folders",
	Short: "Manage mail folders",
}

var foldersListCmd = &cobra.Command{
	Use:   "list",
	Short: "List all folders",
	Run: func(cmd *cobra.Command, args []string) {
		client, ctx := zohoClient()
		folders, err := client.ListFolders(ctx)
		if err != nil {
			exitf("%v", err)
		}
		printJSON(folders)
	},
}

func init() {
	foldersCmd.AddCommand(foldersListCmd)
	rootCmd.AddCommand(foldersCmd)
}


path: cmd/mail.go
content: package cmd

import (
	"context"
	"fmt"
	"strings"

	"github.com/PanthroCorp-Limited/openclaw-skills/zoho-mail/internal/oauth"
	"github.com/PanthroCorp-Limited/openclaw-skills/zoho-mail/internal/zoho"
	"github.com/spf13/cobra"
	"golang.org/x/oauth2"
)

var mailCmd = &cobra.Command{
	Use:   "mail",
	Short: "Read, send, and manage email",
}

func zohoClient() (*zoho.Client, context.Context) {
	ctx := context.Background()

	key := encryptionKey()
	if key == "" {
		exitf("ZOHO_MAIL_TOKEN_KEY is not set")
	}

	cID := clientID()
	cSecret := clientSecret()
	if cID == "" || cSecret == "" {
		exitf("ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET must be set")
	}

	cfg, err := loadConfig(configDir)
	if err != nil {
		exitf("loading config: %v", err)
	}
	if cfg.Email == "" {
		exitf("email not configured; run 'zoho-mail config set --email [REDACTED_SECRET]'")
	}

	token, err := oauth.LoadToken(configDir, key)
	if err != nil {
		exitf("%v", err)
	}

	oauthCfg := oauth.NewOAuthConfig(cID, cSecret)
	ts := oauthCfg.TokenSource(ctx, token)

	// Wrap the token source so that a refreshed token is persisted back to disk.
	pts := &persistingTokenSource{inner: ts, dir: configDir, key: key}

	client := zoho.NewClient(ctx, pts, cfg.Email)
	return client, ctx
}

// persistingTokenSource wraps an oauth2.TokenSource and saves refreshed tokens to disk.
type persistingTokenSource struct {
	inner oauth2.TokenSource
	dir   string
	key   string
	last  *oauth2.Token
}

func (p *persistingTokenSource) Token() (*oauth2.Token, error) {
	t, err := p.inner.Token()
	if err != nil {
		return nil, err
	}
	// Save if the token changed (i.e. was refreshed).
	if p.last == nil || t.AccessToken != p.last.AccessToken {
		_ = oauth.SaveToken(p.dir, t, p.key) // best-effort; don't fail the request
		p.last = t
	}
	return t, nil
}

// mail list

var (
	listFolder string
	listLimit  int
	listStart  int
)

var mailListCmd = &cobra.Command{
	Use:   "list",
	Short: "List messages in a folder",
	Run: func(cmd *cobra.Command, args []string) {
		client, ctx := zohoClient()
		messages, err := client.ListMessages(ctx, listFolder, listLimit, listStart)
		if err != nil {
			exitf("%v", err)
		}
		printJSON(messages)
	},
}

// mail read

var readID string

var mailReadCmd = &cobra.Command{
	Use:   "read",
	Short: "Read a message by ID",
	Run: func(cmd *cobra.Command, args []string) {
		if readID == "" {
			exitf("--id is required")
		}
		client, ctx := zohoClient()
		msg, err := client.GetMessage(ctx, readID)
		if err != nil {
			exitf("%v", err)
		}
		printJSON(msg)
	},
}

// mail send

var (
	sendTo      string
	sendCC      string
	sendSubject string
	sendBody    string
	sendHTML    bool
)

var mailSendCmd = &cobra.Command{
	Use:   "send",
	Short: "Send a new email",
	Run: func(cmd *cobra.Command, args []string) {
		if sendTo == "" {
			exitf("--to is required")
		}
		if sendSubject == "" {
			exitf("--subject is required")
		}
		if sendBody == "" {
			exitf("--body is required")
		}

		cfg, err := loadConfig(configDir)
		if err != nil {
			exitf("loading config: %v", err)
		}

		format := "plaintext"
		if sendHTML {
			format = "html"
		}

		req := zoho.SendRequest{
			FromAddress: cfg.Email,
			ToAddress:   sendTo,
			CCAddress:   sendCC,
			Subject:     sendSubject,
			Content:     sendBody,
			MailFormat:  format,
		}

		client, ctx := zohoClient()
		sent, err := client.SendMessage(ctx, req)
		if err != nil {
			exitf("%v", err)
		}
		printJSON(sent)
	},
}

// mail reply

var (
	replyID   string
	replyBody string
	replyHTML bool
)

var mailReplyCmd = &cobra.Command{
	Use:   "reply",
	Short: "Reply to a message",
	Run: func(cmd *cobra.Command, args []string) {
		if replyID == "" {
			exitf("--id is required")
		}
		if replyBody == "" {
			exitf("--body is required")
		}

		client, ctx := zohoClient()

		original, err := client.GetMessage(ctx, replyID)
		if err != nil {
			exitf("fetching original message: %v", err)
		}

		cfg, err := loadConfig(configDir)
		if err != nil {
			exitf("loading config: %v", err)
		}

		subject := original.Subject
		if !strings.HasPrefix(strings.ToLower(subject), "re:") {
			subject = "Re: " + subject
		}

		format := "plaintext"
		if replyHTML {
			format = "html"
		}

		req := zoho.SendRequest{
			FromAddress: cfg.Email,
			ToAddress:   original.FromAddress,
			Subject:     subject,
			Content:     replyBody,
			MailFormat:  format,
			InReplyTo:   original.MessageID,
		}

		sent, err := client.SendMessage(ctx, req)
		if err != nil {
			exitf("%v", err)
		}
		printJSON(sent)
	},
}

// mail search

var (
	searchQuery string
	searchLimit int
)

var mailSearchCmd = &cobra.Command{
	Use:   "search",
	Short: "Search messages",
	Run: func(cmd *cobra.Command, args []string) {
		if searchQuery == "" {
			exitf("--query is required")
		}
		client, ctx := zohoClient()
		messages, err := client.SearchMessages(ctx, searchQuery, searchLimit)
		if err != nil {
			exitf("%v", err)
		}
		printJSON(messages)
	},
}

// mail delete

var (
	deleteID       string
	deleteFolderID string
)

var mailDeleteCmd = &cobra.Command{
	Use:   "delete",
	Short: "Delete a message",
	Run: func(cmd *cobra.Command, args []string) {
		if deleteID == "" {
			exitf("--id is required")
		}
		client, ctx := zohoClient()
		if err := client.DeleteMessage(ctx, deleteID, deleteFolderID); err != nil {
			exitf("%v", err)
		}
		fmt.Printf("Message %s deleted.\n", deleteID)
	},
}

// mail mark

var (
	markIDs    string
	markRead   bool
	markUnread bool
)

var mailMarkCmd = &cobra.Command{
	Use:   "mark",
	Short: "Mark messages as read or unread",
	Run: func(cmd *cobra.Command, args []string) {
		if markIDs == "" {
			exitf("--ids is required")
		}
		if !markRead && !markUnread {
			exitf("one of --read or --unread is required")
		}
		if markRead && markUnread {
			exitf("--read and --unread are mutually exclusive")
		}

		ids := strings.Split(markIDs, ",")
		for i, id := range ids {
			ids[i] = strings.TrimSpace(id)
		}

		client, ctx := zohoClient()
		if err := client.MarkRead(ctx, ids, markRead); err != nil {
			exitf("%v", err)
		}
		fmt.Printf("Marked %d message(s).\n", len(ids))
	},
}

func init() {
	mailListCmd.Flags().StringVar(&listFolder, "folder", "", "folder ID (default: INBOX)")
	mailListCmd.Flags().IntVar(&listLimit, "limit", 20, "maximum number of messages to return")
	mailListCmd.Flags().IntVar(&listStart, "start", 0, "offset for pagination")

	mailReadCmd.Flags().StringVar(&readID, "id", "", "message ID")

	mailSendCmd.Flags().StringVar(&sendTo, "to", "", "recipient email address")
	mailSendCmd.Flags().StringVar(&sendCC, "cc", "", "CC email address")
	mailSendCmd.Flags().StringVar(&sendSubject, "subject", "", "email subject")
	mailSendCmd.Flags().StringVar(&sendBody, "body", "", "email body")
	mailSendCmd.Flags().BoolVar(&sendHTML, "html", false, "send body as HTML")

	mailReplyCmd.Flags().StringVar(&replyID, "id", "", "message ID to reply to")
	mailReplyCmd.Flags().StringVar(&replyBody, "body", "", "reply body")
	mailReplyCmd.Flags().BoolVar(&replyHTML, "html", false, "send reply as HTML")

	mailSearchCmd.Flags().StringVar(&searchQuery, "query", "", "search query")
	mailSearchCmd.Flags().IntVar(&searchLimit, "limit", 20, "maximum number of results")

	mailDeleteCmd.Flags().StringVar(&deleteID, "id", "", "message ID")
	mailDeleteCmd.Flags().StringVar(&deleteFolderID, "folder", "", "folder ID containing the message")

	mailMarkCmd.Flags().StringVar(&markIDs, "ids", "", "comma-separated list of message IDs")
	mailMarkCmd.Flags().BoolVar(&markRead, "read", false, "mark as read")
	mailMarkCmd.Flags().BoolVar(&markUnread, "unread", false, "mark as unread")

	mailCmd.AddCommand(mailListCmd, mailReadCmd, mailSendCmd, mailReplyCmd, mailSearchCmd, mailDeleteCmd, mailMarkCmd)
	rootCmd.AddCommand(mailCmd)
}


path: cmd/root.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"

	"github.com/spf13/cobra"
)

var (
	version   = "dev"
	configDir string
	outputFmt string
)

// SetVersion sets the version string displayed by --version.
func SetVersion(v string) {
	version = v
}

func defaultConfigDir() string {
	if v := os.Getenv("ZOHO_MAIL_CONFIG_DIR"); v != "" {
		return v
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return filepath.Join(".", ".zoho-mail")
	}
	return filepath.Join(home, ".openclaw", "credentials", "zoho-mail")
}

var rootCmd = &cobra.Command{
	Use:     "zoho-mail",
	Short:   "Full read/write Zoho Mail access for OpenClaw agents",
	Version: version,
}

func init() {
	rootCmd.PersistentFlags().StringVar(&configDir, "config-dir", defaultConfigDir(), "path to config and credential directory")
	rootCmd.PersistentFlags().StringVar(&outputFmt, "output", "json", "output format: json or text")
}

// Execute runs the root command.
func Execute() error {
	return rootCmd.Execute()
}

func encryptionKey() string {
	return os.Getenv("ZOHO_MAIL_TOKEN_KEY")
}

func clientID() string {
	return os.Getenv("ZOHO_CLIENT_ID")
}

func clientSecret() string {
	return os.Getenv("ZOHO_CLIENT_SECRET")
}

func exitf(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
	os.Exit(1)
}

func printJSON(v any) {
	data, _ := json.MarshalIndent(v, "", "  ")
	fmt.Println(string(data))
}


path: dist/artifacts.json
content: [{"name":"metadata.json","path":"dist/metadata.json","internal_type":35,"type":"Metadata"},{"name":"zoho-mail","path":[REDACTED_SECRET],"goos":"linux","goarch":"amd64","goamd64":"v1","target":"linux_amd64_v1","internal_type":4,"type":"Binary","extra":{"Binary":"zoho-mail","Builder":"go","Ext":"","ID":"zoho-mail"}},{"name":"zoho-mail","path":[REDACTED_SECRET],"goos":"darwin","goarch":"amd64","goamd64":"v1","target":"darwin_amd64_v1","internal_type":4,"type":"Binary","extra":{"Binary":"zoho-mail","Builder":"go","Ext":"","ID":"zoho-mail"}},{"name":"zoho-mail","path":"dist/zoho-mail_darwin_arm64_v8.0/zoho-mail","goos":"darwin","goarch":"arm64","goarm64":"v8.0","target":"darwin_arm64_v8.0","internal_type":4,"type":"Binary","extra":{"Binary":"zoho-mail","Builder":"go","Ext":"","ID":"zoho-mail"}},{"name":"zoho-mail","path":"dist/zoho-mail_linux_arm64_v8.0/zoho-mail","goos":"linux","goarch":"arm64","goarm64":"v8.0","target":"linux_arm64_v8.0","internal_type":4,"type":"Binary","extra":{"Binary":"zoho-mail","Builder":"go","Ext":"","ID":"zoho-mail"}},{"name":"zoho-mail_0.3.1_darwin_arm64.tar.gz","path":"dist/zoho-mail_0.3.1_darwin_arm64.tar.gz","goos":"darwin","goarch":"arm64","goarm64":"v8.0","target":"darwin_arm64_v8.0","internal_type":1,"type":"Archive","extra":{"Binaries":["zoho-mail"],"Checksum":"sha256:14a061732206e8e79fd35d8e35e46f2f50c3f38aa49977c2f31e0c5664635aa5","Files":["CHANGELOG.md","LICENSE","README.md"],"Format":"tar.gz","ID":"zoho-mail","WrappedIn":""}},{"name":"zoho-mail_0.3.1_linux_arm64.tar.gz","path":"dist/zoho-mail_0.3.1_linux_arm64.tar.gz","goos":"linux","goarch":"arm64","goarm64":"v8.0","target":"linux_arm64_v8.0","internal_type":1,"type":"Archive","extra":{"Binaries":["zoho-mail"],"Checksum":"sha256:51cdfd67cd06285b6ab80a010ec0e89a0ed47af21a91b011380baa5c97936020","Files":["CHANGELOG.md","LICENSE","README.md"],"Format":"tar.gz","ID":"zoho-mail","WrappedIn":""}},{"name":"zoho-mail_0.3.1_darwin_amd64.tar.gz","path":"dist/zoho-mail_0.3.1_darwin_amd64.tar.gz","goos":"darwin","goarch":"amd64","goamd64":"v1","target":"darwin_amd64_v1","internal_type":1,"type":"Archive","extra":{"Binaries":["zoho-mail"],"Checksum":"sha256:dc03665cc965458f4eb4dc290a69c787809224bf149b2a7ecde10ee42989f65b","Files":["CHANGELOG.md","LICENSE","README.md"],"Format":"tar.gz","ID":"zoho-mail","WrappedIn":""}},{"name":"zoho-mail_0.3.1_linux_amd64.tar.gz","path":"dist/zoho-mail_0.3.1_linux_amd64.tar.gz","goos":"linux","goarch":"amd64","goamd64":"v1","target":"linux_amd64_v1","internal_type":1,"type":"Archive","extra":{"Binaries":["zoho-mail"],"Checksum":"sha256:b98e7f80749a127cec5439d59cba0dec5f905b6f41f8998871f9b29d5b2c34b0","Files":["CHANGELOG.md","LICENSE","README.md"],"Format":"tar.gz","ID":"zoho-mail","WrappedIn":""}},{"name":"checksums.txt","path":"dist/checksums.txt","internal_type":12,"type":"Checksum","extra":{}}]

path: dist/checksums.txt
content: dc03665cc965458f4eb4dc290a69c787809224bf149b2a7ecde10ee42989f65b  zoho-mail_0.3.1_darwin_amd64.tar.gz
14a061732206e8e79fd35d8e35e46f2f50c3f38aa49977c2f31e0c5664635aa5  zoho-mail_0.3.1_darwin_arm64.tar.gz
b98e7f80749a127cec5439d59cba0dec5f905b6f41f8998871f9b29d5b2c34b0  zoho-mail_0.3.1_linux_amd64.tar.gz
51cdfd67cd06285b6ab80a010ec0e89a0ed47af21a91b011380baa5c97936020  zoho-mail_0.3.1_linux_arm64.tar.gz


path: dist/config.yaml
content: version: 2
project_name: zoho-mail
release:
  disable: "true"
builds:
  - id: zoho-mail
    goos:
      - linux
      - darwin
    goarch:
      - amd64
      - arm64
    goamd64:
      - v1
    go386:
      - sse2
    goarm:
      - "6"
    goarm64:
      - v8.0
    gomips:
      - hardfloat
    goppc64:
      - power8
    goriscv64:
      - rva20u64
    targets:
      - linux_amd64_v1
      - linux_arm64_v8.0
      - darwin_amd64_v1
      - darwin_arm64_v8.0
    dir: .
    main: .
    binary: zoho-mail
    builder: go
    tool: go
    command: build
    ldflags:
      - -s -w
      - -X main.version={{ .Version }}
    env:
      - CGO_ENABLED=0
archives:
  - id: zoho-mail
    builds_info:
      mode: 493
    name_template: zoho-mail_{{ .Version }}_{{ .Os }}_{{ .Arch }}
    formats:
      - tar.gz
    files:
      - src: license*
      - src: LICENSE*
      - src: readme*
      - src: README*
      - src: changelog*
      - src: CHANGELOG*
    format: tar.gz
srpm:
  nfpmrpm: {}
  package_name: zoho-mail
  file_name_template: '{{ .PackageName }}-{{ .Version }}.src.rpm'
  bins:
    zoho-mail: '%{goipath}'
snapshot:
  version_template: '{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}'
checksum:
  name_template: checksums.txt
  algorithm: sha256
docker_digest:
  name_template: digests.txt
changelog:
  disable: "true"
  format: '{{ .SHA }} {{ .Message }}'
dist: dist
env_files:
  github_token: ~/.config/goreleaser/github_token
  gitlab_token: ~/.config/goreleaser/gitlab_token
  gitea_token: ~/.config/goreleaser/gitea_token
source:
  name_template: '{{ .ProjectName }}-{{ .Version }}'
  format: tar.gz
gomod:
  gobinary: go
announce:
  twitter:
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
  mastodon:
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
    server: ""
  reddit:
    title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
    url_template: '{{ .ReleaseURL }}'
  slack:
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
    username: GoReleaser
  discord:
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
    author: GoReleaser
    color: "3888754"
    icon_url: https://goreleaser.com/static/avatar.png
  teams:
    title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
    color: '#2D313E'
    icon_url: https://goreleaser.com/static/avatar.png
  smtp:
    subject_template: '{{ .ProjectName }} {{ .Tag }} is out!'
    body_template: 'You can view details from: {{ .ReleaseURL }}'
  mattermost:
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
    title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
    username: GoReleaser
  linkedin:
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
  telegram:
    message_template: '{{ print .ProjectName " " .Tag " is out! Check it out at " .ReleaseURL | mdv2escape }}'
    parse_mode: MarkdownV2
  webhook:
    message_template: '{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}'
    content_type: application/json; charset=utf-8
    expected_status_codes:
      - 200
      - 201
      - 202
      - 204
  opencollective:
    title_template: '{{ .Tag }}'
    message_template: '{{ .ProjectName }} {{ .Tag }} is out!<br/>Check it out at <a href="{{ .ReleaseURL }}">{{ .ReleaseURL }}</a>'
  bluesky:
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
  discourse:
    title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
    message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
    username: system
git:
  tag_sort: -version:refname
mcp:
  name: ""
  title: ""
  auth:
    type: none
github_urls:
  download: https://github.com
gitlab_urls:
  download: https://gitlab.com


path: dist/metadata.json
content: {"project_name":"zoho-mail","tag":"v0.3.1","previous_tag":"aws-s3/v0.2.0","version":"0.3.1","commit":[REDACTED_SECRET],"date":"2026-04-08T08:37:12.528582521Z","runtime":{"goos":"linux","goarch":"amd64"}}

path: internal/crypto/encrypt.go
content: package crypto

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"errors"
	"fmt"
	"io"

	"golang.org/x/crypto/hkdf"
)

const (
	saltSize = 16
	keySize  = 32 // AES-256
)

// Encrypt encrypts plaintext using AES-256-GCM with a key derived via HKDF-SHA256.
// The passphrase is the raw key material (e.g. from ZOHO_MAIL_TOKEN_KEY).
// Output format: salt (16 bytes) || nonce (12 bytes) || ciphertext+tag.
func Encrypt(plaintext []byte, passphrase string) ([]byte, error) {
	if passphrase == "" {
		return nil, errors.New("encryption key must not be empty")
	}

	salt := make([]byte, saltSize)
	if _, err := io.ReadFull(rand.Reader, salt); err != nil {
		return nil, fmt.Errorf("generating salt: %w", err)
	}

	key, err := deriveKey(passphrase, salt)
	if err != nil {
		return nil, err
	}

	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, fmt.Errorf("creating cipher: %w", err)
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, fmt.Errorf("creating GCM: %w", err)
	}

	nonce := make([]byte, gcm.NonceSize())
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		return nil, fmt.Errorf("generating nonce: %w", err)
	}

	ciphertext := gcm.Seal(nil, nonce, plaintext, nil)

	// salt || nonce || ciphertext
	out := make([]byte, 0, saltSize+len(nonce)+len(ciphertext))
	out = append(out, salt...)
	out = append(out, nonce...)
	out = append(out, ciphertext...)

	return out, nil
}

// Decrypt decrypts data produced by Encrypt.
func Decrypt(data []byte, passphrase string) ([]byte, error) {
	if passphrase == "" {
		return nil, errors.New("encryption key must not be empty")
	}

	if len(data) < saltSize+12 {
		return nil, errors.New("ciphertext too short")
	}

	salt := data[:saltSize]
	key, err := deriveKey(passphrase, salt)
	if err != nil {
		return nil, err
	}

	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, fmt.Errorf("creating cipher: %w", err)
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, fmt.Errorf("creating GCM: %w", err)
	}

	nonceSize := gcm.NonceSize()
	if len(data) < saltSize+nonceSize {
		return nil, errors.New("ciphertext too short for nonce")
	}

	nonce := data[saltSize : saltSize+nonceSize]
	ciphertext := data[saltSize+nonceSize:]

	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return nil, fmt.Errorf("decryption failed (wrong key or tampered data): %w", err)
	}

	return plaintext, nil
}

func deriveKey(passphrase string, salt []byte) ([]byte, error) {
	r := hkdf.New(sha256.New, []byte(passphrase), salt, []byte("zoho-mail-token"))
	key := make([]byte, keySize)
	if _, err := io.ReadFull(r, key); err != nil {
		return nil, fmt.Errorf("deriving key: %w", err)
	}
	return key, nil
}


path: internal/oauth/oauth.go
content: package oauth

import (
	"context"
	"fmt"
	"net/url"

	"golang.org/x/oauth2"
)

const (
	authURL  = "https://accounts.zoho.eu/oauth/v2/auth"
	tokenURL = "https://accounts.zoho.eu/oauth/v2/token"

	// RedirectURL must be registered in the Zoho API Console.
	RedirectURL = "http://localhost:8080/callback"
)

// Scopes required for full mail access.
var Scopes = []string{
	"ZohoMail.messages.ALL",
	"ZohoMail.folders.ALL",
	"ZohoMail.accounts.READ",
}

var zohoEndpoint = oauth2.Endpoint{
	AuthURL:  authURL,
	TokenURL: tokenURL,
}

// NewOAuthConfig builds an OAuth2 config for the Zoho Mail API.
func NewOAuthConfig(clientID, clientSecret string) *oauth2.Config {
	return &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		Endpoint:     zohoEndpoint,
		RedirectURL:  RedirectURL,
		Scopes:       Scopes,
	}
}

// InteractiveLogin performs the OAuth2 authorisation code flow for a headless server.
// It prints a URL for the operator to visit, then reads the full redirect URL from stdin
// and extracts the authorisation code.
func InteractiveLogin(ctx context.Context, cfg *oauth2.Config) (*oauth2.Token, error) {
	authURL := cfg.AuthCodeURL("state", oauth2.AccessTypeOffline)

	fmt.Println("Visit this URL in your browser to authorise access:")
	fmt.Println()
	fmt.Println("  " + authURL)
	fmt.Println()
	fmt.Printf("After authorising, you will be redirected to %s\n", RedirectURL)
	fmt.Println("Paste the full redirect URL here: ")

	var rawURL string
	if _, err := fmt.Scan(&rawURL); err != nil {
		return nil, fmt.Errorf("reading redirect URL: %w", err)
	}

	u, err := url.Parse(rawURL)
	if err != nil {
		return nil, fmt.Errorf("parsing redirect URL: %w", err)
	}

	code := u.Query().Get("code")
	if code == "" {
		return nil, fmt.Errorf("no 'code' parameter found in redirect URL %q", rawURL)
	}

	token, err := cfg.Exchange(ctx, code)
	if err != nil {
		return nil, fmt.Errorf("exchanging authorisation code: %w", err)
	}

	return token, nil
}


path: internal/oauth/token.go
content: package oauth

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/PanthroCorp-Limited/openclaw-skills/zoho-mail/internal/crypto"
	"golang.org/x/oauth2"
)

const tokenFileName = "token.enc"

// StoredToken wraps an oauth2.Token with metadata.
type StoredToken struct {
	AccessToken  string    `json:"access_token"`
	TokenType    string    `json:"token_type"`
	RefreshToken string    `json:"refresh_token"`
	Expiry       time.Time `json:"expiry"`
}

// SaveToken encrypts and writes the OAuth token to disk.
func SaveToken(dir string, token *oauth2.Token, encryptionKey string) error {
	if encryptionKey == "" {
		return errors.New("ZOHO_MAIL_TOKEN_KEY is not set; refusing to store token unencrypted")
	}

	st := StoredToken{
		AccessToken:  token.AccessToken,
		TokenType:    token.TokenType,
		RefreshToken: token.RefreshToken,
		Expiry:       token.Expiry,
	}

	data, err := json.Marshal(st)
	if err != nil {
		return fmt.Errorf("marshalling token: %w", err)
	}

	encrypted, err := crypto.Encrypt(data, encryptionKey)
	if err != nil {
		return fmt.Errorf("encrypting token: %w", err)
	}

	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("creating token directory: %w", err)
	}

	path := filepath.Join(dir, tokenFileName)
	return os.WriteFile(path, encrypted, 0o600)
}

// LoadToken reads and decrypts the OAuth token from disk.
func LoadToken(dir string, encryptionKey string) (*oauth2.Token, error) {
	if encryptionKey == "" {
		return nil, errors.New("ZOHO_MAIL_TOKEN_KEY is not set; cannot decrypt token")
	}

	path := filepath.Join(dir, tokenFileName)
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, fmt.Errorf("no token found; run 'zoho-mail auth login' first")
		}
		return nil, fmt.Errorf("reading token: %w", err)
	}

	decrypted, err := crypto.Decrypt(data, encryptionKey)
	if err != nil {
		return nil, fmt.Errorf("decrypting token: %w", err)
	}

	var st StoredToken
	if err := json.Unmarshal(decrypted, &st); err != nil {
		return nil, fmt.Errorf("parsing token: %w", err)
	}

	return &oauth2.Token{
		AccessToken:  st.AccessToken,
		TokenType:    st.TokenType,
		RefreshToken: st.RefreshToken,
		Expiry:       st.Expiry,
	}, nil
}

// DeleteToken removes the stored token file.
func DeleteToken(dir string) error {
	path := filepath.Join(dir, tokenFileName)
	err := os.Remove(path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("removing token: %w", err)
	}
	return nil
}

// TokenExists checks whether a token file exists.
func TokenExists(dir string) bool {
	path := filepath.Join(dir, tokenFileName)
	_, err := os.Stat(path)
	return err == nil
}


path: internal/zoho/client.go
content: package zoho

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"golang.org/x/oauth2"
)

const apiBase = "https://mail.zoho.eu/api"

// Client provides access to the Zoho Mail REST API.
type Client struct {
	http      *http.Client
	email     string
	accountID string // cached after first discovery
}

// NewClient creates a Zoho Mail API client using the provided token source.
// email is the configured mailbox address (used to identify the account).
func NewClient(ctx context.Context, ts oauth2.TokenSource, email string) *Client {
	return &Client{
		http:  oauth2.NewClient(ctx, ts),
		email: email,
	}
}

// apiResponse is the envelope returned by the Zoho Mail API.
type apiResponse struct {
	Status struct {
		Code        int    `json:"code"`
		Description string `json:"description"`
	} `json:"status"`
	Data json.RawMessage `json:"data"`
}

func (c *Client) do(req *http.Request) (*apiResponse, error) {
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("http request: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading response body: %w", err)
	}

	if resp.StatusCode == http.StatusNoContent {
		return &apiResponse{}, nil
	}

	var ar apiResponse
	if err := json.Unmarshal(body, &ar); err != nil {
		return nil, fmt.Errorf("parsing response (status %d): %w", resp.StatusCode, err)
	}

	if ar.Status.Code != 200 {
		return nil, fmt.Errorf("zoho api error %d: %s", ar.Status.Code, ar.Status.Description)
	}

	return &ar, nil
}

func (c *Client) get(ctx context.Context, path string, params url.Values) (*apiResponse, error) {
	u := apiBase + path
	if len(params) > 0 {
		u += "?" + params.Encode()
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, fmt.Errorf("building request: %w", err)
	}

	return c.do(req)
}

func (c *Client) post(ctx context.Context, path string, body any) (*apiResponse, error) {
	data, err := json.Marshal(body)
	if err != nil {
		return nil, fmt.Errorf("marshalling request: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+path, bytes.NewReader(data))
	if err != nil {
		return nil, fmt.Errorf("building request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	return c.do(req)
}

func (c *Client) put(ctx context.Context, path string, body any) (*apiResponse, error) {
	data, err := json.Marshal(body)
	if err != nil {
		return nil, fmt.Errorf("marshalling request: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBase+path, bytes.NewReader(data))
	if err != nil {
		return nil, fmt.Errorf("building request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	return c.do(req)
}

func (c *Client) delete(ctx context.Context, path string, params url.Values) (*apiResponse, error) {
	u := apiBase + path
	if len(params) > 0 {
		u += "?" + params.Encode()
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
	if err != nil {
		return nil, fmt.Errorf("building request: %w", err)
	}

	return c.do(req)
}

// accountEntry is the shape returned by GET /accounts.
type accountEntry struct {
	AccountID    string `json:"accountId"`
	EmailAddress string `json:"emailAddress"`
}

// AccountID returns the Zoho account ID for the configured email address,
// fetching it from the API on first call and caching it thereafter.
func (c *Client) AccountID(ctx context.Context) (string, error) {
	if c.accountID != "" {
		return c.accountID, nil
	}

	ar, err := c.get(ctx, "/accounts", nil)
	if err != nil {
		return "", fmt.Errorf("listing accounts: %w", err)
	}

	var accounts []accountEntry
	if err := json.Unmarshal(ar.Data, &accounts); err != nil {
		return "", fmt.Errorf("parsing accounts: %w", err)
	}

	for _, a := range accounts {
		if strings.EqualFold(a.EmailAddress, c.email) {
			c.accountID = a.AccountID
			return c.accountID, nil
		}
	}

	return "", fmt.Errorf("no account found for email %q; check 'zoho-mail config show'", c.email)
}

// Message is a summary of a Zoho mail message.
type Message struct {
	MessageID    string `json:"messageId"`
	Subject      string `json:"subject"`
	FromAddress  string `json:"fromAddress"`
	ToAddress    string `json:"toAddress"`
	ReceivedTime string `json:"receivedTime"`
	FolderID     string `json:"folderId"`
	IsRead       bool   `json:"isRead"`
	Summary      string `json:"summary"`
}

// MessageDetail is a full message with content.
type MessageDetail struct {
	Message
	Content     string `json:"content"`
	HTMLContent string `json:"htmlContent"`
}

// SendRequest contains the fields needed to send or reply to a message.
type SendRequest struct {
	FromAddress string `json:"fromAddress"`
	ToAddress   string `json:"toAddress"`
	CCAddress   string `json:"ccAddress,omitempty"`
	Subject     string `json:"subject"`
	Content     string `json:"content"`
	MailFormat  string `json:"mailFormat"` // "html" or "plaintext"
	InReplyTo   string `json:"inReplyTo,omitempty"`
}

// Folder represents a Zoho Mail folder.
type Folder struct {
	FolderID     string `json:"folderId"`
	FolderName   string `json:"folderName"`
	FolderType   string `json:"folderType"`
	MessageCount int    `json:"messageCount"`
	UnreadCount  int    `json:"unreadCount"`
}

// ListMessages returns messages in the given folder (pass empty string for INBOX).
func (c *Client) ListMessages(ctx context.Context, folderID string, limit, start int) ([]Message, error) {
	accountID, err := c.AccountID(ctx)
	if err != nil {
		return nil, err
	}

	params := url.Values{
		"limit": {strconv.Itoa(limit)},
		"start": {strconv.Itoa(start)},
	}
	if folderID != "" {
		params.Set("folderId", folderID)
	}

	ar, err := c.get(ctx, fmt.Sprintf("/accounts/%s/messages/view", accountID), params)
	if err != nil {
		return nil, fmt.Errorf("listing messages: %w", err)
	}

	var messages []Message
	if err := json.Unmarshal(ar.Data, &messages); err != nil {
		return nil, fmt.Errorf("parsing messages: %w", err)
	}

	return messages, nil
}

// GetMessage returns the full content of a message by ID.
func (c *Client) GetMessage(ctx context.Context, msgID string) (MessageDetail, error) {
	accountID, err := c.AccountID(ctx)
	if err != nil {
		return MessageDetail{}, err
	}

	ar, err := c.get(ctx, fmt.Sprintf("/accounts/%s/messages/%s", accountID, msgID), nil)
	if err != nil {
		return MessageDetail{}, fmt.Errorf("getting message %s: %w", msgID, err)
	}

	var msg MessageDetail
	if err := json.Unmarshal(ar.Data, &msg); err != nil {
		return MessageDetail{}, fmt.Errorf("parsing message: %w", err)
	}

	return msg, nil
}

// SendMessage sends a new message or reply.
func (c *Client) SendMessage(ctx context.Context, req SendRequest) (Message, error) {
	accountID, err := c.AccountID(ctx)
	if err != nil {
		return Message{}, err
	}

	if req.MailFormat == "" {
		req.MailFormat = "plaintext"
	}

	ar, err := c.post(ctx, fmt.Sprintf("/accounts/%s/messages", accountID), req)
	if err != nil {
		return Message{}, fmt.Errorf("sending message: %w", err)
	}

	var sent Message
	if err := json.Unmarshal(ar.Data, &sent); err != nil {
		return Message{}, fmt.Errorf("parsing send response: %w", err)
	}

	return sent, nil
}

// DeleteMessage deletes a message.
func (c *Client) DeleteMessage(ctx context.Context, msgID, folderID string) error {
	accountID, err := c.AccountID(ctx)
	if err != nil {
		return err
	}

	params := url.Values{}
	if folderID != "" {
		params.Set("folderId", folderID)
	}

	_, err = c.delete(ctx, fmt.Sprintf("/accounts/%s/messages/%s", accountID, msgID), params)
	if err != nil {
		return fmt.Errorf("deleting message %s: %w", msgID, err)
	}

	return nil
}

// MarkRead marks the given message IDs as read or unread.
func (c *Client) MarkRead(ctx context.Context, msgIDs []string, isRead bool) error {
	accountID, err := c.AccountID(ctx)
	if err != nil {
		return err
	}

	body := map[string]any{
		"messageId": msgIDs,
		"isRead":    isRead,
	}

	_, err = c.put(ctx, fmt.Sprintf("/accounts/%s/messages", accountID), body)
	if err != nil {
		return fmt.Errorf("marking messages: %w", err)
	}

	return nil
}

// SearchMessages searches for messages matching the query.
func (c *Client) SearchMessages(ctx context.Context, query string, limit int) ([]Message, error) {
	accountID, err := c.AccountID(ctx)
	if err != nil {
		return nil, err
	}

	params := url.Values{
		"searchKey":   {query},
		"searchLimit": {strconv.Itoa(limit)},
	}

	ar, err := c.get(ctx, fmt.Sprintf("/accounts/%s/messages/search", accountID), params)
	if err != nil {
		return nil, fmt.Errorf("searching messages: %w", err)
	}

	var messages []Message
	if err := json.Unmarshal(ar.Data, &messages); err != nil {
		return nil, fmt.Errorf("parsing search results: %w", err)
	}

	return messages, nil
}

// ListFolders returns all folders for the account.
func (c *Client) ListFolders(ctx context.Context) ([]Folder, error) {
	accountID, err := c.AccountID(ctx)
	if err != nil {
		return nil, err
	}

	ar, err := c.get(ctx, fmt.Sprintf("/accounts/%s/folders", accountID), nil)
	if err != nil {
		return nil, fmt.Errorf("listing folders: %w", err)
	}

	var folders []Folder
	if err := json.Unmarshal(ar.Data, &folders); err != nil {
		return nil, fmt.Errorf("parsing folders: %w", err)
	}

	return folders, nil
}


path: main.go
content: package main

import (
	"os"

	"github.com/PanthroCorp-Limited/openclaw-skills/zoho-mail/cmd"
)

var version = "dev"

func main() {
	cmd.SetVersion(version)
	if err := cmd.Execute(); err != nil {
		os.Exit(1)
	}
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)