### Zoho Mail — a doubled group name, ids scoped to folders, and paging by sequence number

Four things cause most failures here: the message commands live under `mail mail`, every path needs an **accountId** (and the admin paths a different id again), **`--start` is a sequence number rather than a page**, and a message id is meaningless without the folder it lives in.

#### The command is `zone mail mail list`, not `zone mail list`

The service is `mail` and one of its twelve groups is also `mail`. Dropping the repeat is a usage error, not a 404:

```
error: unknown command 'list'
```

The other groups read normally: `zone mail folder list`, `zone mail account list`, `zone mail org users`.

#### Two context ids, from two different places

```bash
zone mail account list                 # accountId, and the valid From addresses
zone ctx mail accountId=<id>           # fills every :accountId path segment

zone mail org lookup                   # the org record — this is where zoid comes from
zone ctx mail zoid=<zoid>              # unlocks the 69 admin commands under `org`
```

`accountId` is the mailbox; `zoid` is the organization. The account record carries **`zuid`**, which is the *user* id and is not the zoid — using it fails. `org lookup` is the only org call that needs no zoid, so it is how you bootstrap the rest; every other `org` command 404s or reports a missing zoid until ctx is set.

#### Listing and paging — `--start` overlaps

```bash
zone mail mail list --limit 50 --start 1 --toon
zone mail mail list -f <folderId> --limit 50 --start 51
```

`--start` is a **1-based sequence number, not a page index**. Verified against a live mailbox:

| call | rows returned |
|---|---|
| `--limit 2 --start 1` | messages 1, 2 |
| `--limit 2 --start 2` | messages **2**, 3 |
| `--limit 2 --start 3` | messages **3**, 4 |

So paging is `start = 1, 1+limit, 1+2*limit, …`. Incrementing `--start` by one, as you would a page number, re-fetches everything but one row.

`--limit` is **silently clamped to 200** — asking for 201 returns 200 with no warning, so a loop that trusts its own limit will quietly skip records. `--start 0` behaves as `--start 1`.

**Archived messages are a separate listing.** Without `--include-archive` a folder lists only non-archived mail; with it, **only archived** mail — the two sets never overlap (verified on Inbox: 9,707 vs 11). A full scan of a folder is two passes, one with the flag and one without.

#### A message id is scoped to its folder

```bash
zone mail mail read <messageId> -f Inbox
zone mail attach download <messageId> <attachmentId> -f Inbox -o ./file.pdf
```

`read`, `meta`, `header`, `source`, `inline-images` and the attachment commands all take `-f/--folder`, which accepts **either the folder id or its name**. Omitting it is a usage error before any request goes out:

```
BAD_INPUT: --folder is required
```

Folder ids come from `zone mail folder list` (a real mailbox here has 47, including the system ones: Inbox, Drafts, Templates, Sent, Spam, Trash, Outbox).

#### Search takes `key:value` — never a bare word

```bash
zone mail mail search "subject:invoice" --limit 20
zone mail mail search "from:accounts@acme.com" --limit 50 --start 51
zone mail mail search "entire:contract renewal" --received-before 1756000000000
```

A bare term is rejected outright — `zone mail mail search "invoice"` exits **5** with `HTTP 400: Invalid Input`. Keys verified against the live API:

| Key | Takes |
|---|---|
| `entire:` | any part of the message — the closest thing to a plain search |
| `from:` `to:` `cc:` `bcc:` `sender:` | an address or a domain fragment |
| `subject:` `content:` | text |
| `attachment:` `attachmentname:` | filename fragment |
| `hasAttachment:` | `true` |
| `folder:` | folder **id or name** |
| `label:` | the label's **display name** — an id is rejected |
| `flag:` `priority:` `newer:` | flag name, priority, age |

`label:` is the sharp edge: it wants the name, and an unknown name returns **400, not an empty result**. Read the real names from `zone mail label list` before searching on one.

Search paging uses the same overlapping `--start` as `list`.

#### Sending — the From address must already belong to the account

```bash
zone mail mail send --from sales@acme.com --to client@example.com \
  --subject "Proposal" --content-file ./body.html --format html \
  --attach ./proposal.pdf
```

Valid senders are exactly the `sendMailDetails[].fromAddress` entries on the account record — read them with `zone mail account list`. Any other address fails, including an alias or a group address that exists in the org but is not configured on this mailbox. Check the list before sending on behalf of a shared address.

`--attach` uploads each file first, then posts the message. `--content-file` avoids shell-quoting a whole HTML body. `--data` overrides every other flag.

#### Changing messages: one bulk endpoint, two kinds of delete

Most mutations go through a single bulk endpoint and take **message ids**, so they work on many at once:

```bash
zone mail mail mark-read <messageIds>
zone mail mail move <messageIds> -t <folderId>
zone mail mail label <messageIds> --data '{"labelid":"<id>"}'
zone mail mail archive <messageIds>
```

Deletion has two forms, and they are not the same operation:

| Command | Effect |
|---|---|
| `zone mail mail delete <messageIds>` | moves to **Trash** — recoverable |
| `zone mail mail remove <folderId> <messageId>` | deletes one message; **`--expunge` bypasses Trash entirely** |

`remove --expunge` is unrecoverable. Prefer `delete` unless the intent is explicitly to purge.

Zoho has **no delete mode** on the bulk endpoint (`deleteMessage` and `moveToTrash` both answer `Invalid mode`), so `delete` resolves the Trash folder and moves the ids there. Raw `zone mail mail update` calls need the same approach.

**Archived mail ignores a move.** `moveMessage` on an archived message answers **200 and leaves it where it is** — in one real cleanup 2,757 of 12,223 "moved" messages had not moved. `move` and `delete` therefore send `unArchiveMails` for the same ids first (a no-op for mail that is not archived), in batches of 100. `--no-unarchive` turns that off. If you call `zone mail mail update` with `moveMessage` yourself, unarchive first and rescan to confirm.

Threads mirror all of this against a separate endpoint (`updatethread`) — `zone mail thread list`, `zone mail thread move`, `zone mail thread delete`, `zone mail thread mark-read` and so on. A thread operation affects every message in the conversation.

#### Bulk runs hit a throttle that says "Invalid Input"

Thousands of `updatemessage` calls in a burst trip Zoho's URL throttle, which is **not a 429**: it arrives as `{"data":{"errorCode":"URL_ROLLING_THROTTLES_LIMIT_EXCEEDED"},"status":{"code":404,"description":"Invalid Input"}}`. zone retries it with a 10s/20s/30s backoff; if it still fails, the error is `RETRYABLE` (exit 7). Wait about a minute and re-run the same batch. The payload is not the problem.

#### Admin: 69 commands under `org`

```bash
zone mail org lookup                      # zoid, org name, user and mailbox counts
zone mail org users --limit 50 --start 1
zone mail org user-add --data '{"primaryEmailAddress":"new@acme.com","password":"…"}'
zone mail org audit
zone mail org smtp-logs
zone mail org policy-update <policyId> --data '{…}'
```

These cover users, aliases, domains, groups, policies, spam lists, allowed IPs, storage, login history and per-user toggles (IMAP, POP, ActiveSync, incoming/outgoing blocks). They need `zoid` in ctx and an admin token; a normal mailbox token gets a permission error rather than an empty result.

#### Snapshotting

```bash
zone mail pull -o ./mail-config
```

Writes accounts, folders, labels, signatures, tasks, notes, bookmarks and org config to disk. Commit it and re-run after changes to diff what actually moved — see the `zoho-sdlc` skill for using that as the record of a change.

#### Admin and tasks added in 0.8.3

From Zoho's `zohomail-oas` repo:

- **Policies** — `org create-access-restriction`, `create-account-restriction`, `create-forward-restriction`, `create-mail-restriction` (the list forms already existed).
- **Groups** — `org delete-group` is the **bulk** delete on `/organization/{zoid}/groups`; the single delete stays `org group-delete`. `org get-content-of-email-held-for-moderation` reads a moderated message.
- **`audit-log activity`** is `GET /organization/{zoid}/activity` — a different endpoint from `org audit` (`…/accounts/reports/auditRecords`). Both work.
- **Task categories** — personal (`/tasks/me/categories`) and group (`/tasks/groups/{groupId}/categories`), full CRUD under `task`.

#### Errors → what to do

| What you see | Meaning | Do |
|---|---|---|
| `unknown command 'list'` | dropped the repeated group | `zone mail mail list` |
| `BAD_INPUT: --folder is required` | message ids are folder-scoped | add `-f <folderId or name>` |
| `HTTP 400: Invalid Input` on a search | bare word, or an unknown `label:` name | use `key:value`; take label names from `zone mail label list` |
| missing `<zoid>` on any `org` command | ctx has no org id | `zone mail org lookup`, then `zone ctx mail zoid=<zoid>` |
| duplicate rows while paging | `--start` treated as a page | advance `--start` by `--limit` |
| a loop silently missing records | `--limit` was clamped to 200 | page with `--limit 200` |
| send rejected for a valid-looking address | not in this account's `sendMailDetails` | `zone mail account list` and use a listed `fromAddress` |
| a deleted message is not in Trash | `remove --expunge` was used | nothing to do — it is gone |
| `Invalid mode` from `mail update` | no such mode (e.g. `deleteMessage`, `unArchive`) | use `mail delete`, or `unArchiveMails` |
| move/delete said ok but the message stayed put | it was archived | `mail move`/`delete` without `--no-unarchive`, or `mail unarchive` first |
| a folder scan misses messages | archived mail is listed only with `--include-archive` | scan twice, with and without it |
| `RETRYABLE` with `URL_ROLLING_THROTTLES_LIMIT_EXCEEDED` | bulk burst hit the URL throttle | wait ~60s, re-run, add pauses between batches |
| exit 3 | not signed in — Mail is its own consent | human runs `zone login mail` |
| exit 4 | token lacks the Mail scopes | human re-logs in that service |

> Command names, paths, required flags and the group layout come from the installed specs. The paging behaviour, the 200 clamp, the `--folder` requirement, the search-key vocabulary, the `label:`-takes-a-name rule and the `zuid` vs `zoid` distinction were **verified live against a real Zoho Mail account** while writing this. Send behaviour is described from the account's own `sendMailDetails` record; no mail was sent and nothing was deleted during verification. The archive, delete-mode and throttle behaviour was verified live on 13 Sep 2026 during a 12,000-message cleanup.
