Skip to main content
Glama
nephilus

Zoho Mail Read-Only Container Client

by nephilus
README.md
# Zoho Mail read-only container client

An outbound-only, one-shot local client for Zoho's official hosted Mail MCP, with a separate REST adapter for bounded aggregate DMARC attachments. No listener, public endpoint, port forwarding, mailbox database, sending, deleting, marking read or third-party report upload. The machine must be online for local tasks. This is not an automatic ChatGPT connection or task-delivery service.

## Status and authorization

The private owner launcher supplies a trusted `mcpHost` pin alongside `mcpUrl` in the stdin configuration. Dedicated custom servers can use a server-specific `zohomcp.com` hostname, as documented in [Zoho's custom-server overview](https://docs.catalyst.zoho.com/en/ai-toolkit/catalyst-mcp-server/overview/). The hostname is separately reviewed and approved, never copied from a tool argument or inferred from a URL. The client requires that exact host and exact endpoint for MCP requests; it rejects other tenants, lookalikes, extra subdomains, nonstandard ports, userinfo and redirects. The fallback console-host policy supports existing configurations only. No wildcard request matching is permitted. Owner identifiers and policy files must stay outside this repository/image.

`inspect-auth.mjs` reads the same private configuration on stdin and performs MCP initialize with HTTP POST and Accept for JSON/SSE. It recognizes successful bounded JSON or SSE initialization without requiring an OAuth challenge. It prints numeric HTTP status, allowlisted content type and fixed MCP/error categories, never URLs, server names, session IDs, messages, tokens or raw bodies. Only an actual 401/403 advertised challenge triggers bounded metadata GETs. Protected-resource metadata must stay on the exact owner origin; separately allowlisted authorization metadata receives no Authorization header. Discovery does not register a client, exchange tokens, initiate consent or call mailbox tools. Initialization success is not proof of Mail tool authorization; existing provider-managed connections may already supply that authorization. Do not change authorization type or create extra OAuth grants simply because no challenge appears.

Offline tests exercise the official MCP SDK with synthetic protocol responses, account/tool policy and bounded XML/gzip/ZIP parsing. Live authorization and provider schemas are unverified. OAuth grants and persistent credential setup are deliberately absent: endpoint possession alone does not establish successful OAuth authorization. A challenge stops with `mcp_authorization_required`; the client does not create accounts/clients/grants, launch browser OAuth or persist/refresh MCP OAuth tokens. An already authorized access token can be supplied transiently. Unattended MCP OAuth lifecycle support must be completed and verified before production scheduling.

Zoho documents custom HTTP/Streamable clients and separate OAuth authorization. Configure a dedicated Mail server by selecting individual read tools, not broad category checkboxes: `getMailAccounts`, `getAccountDetails`, `listEmails`, `SearchEmails`, `getMessageContent`, `getMessageAttachmentInfo`, `getAllFolders`, `getFolder`, choosing only those needed. Client discovery rejects any configured write or unknown tool. Provider prefixes, argument schemas and account response shape must be verified after authorization; the client refuses mismatches rather than guessing mappings. It verifies the pinned primary mailbox via `getMailAccounts` before scoped reads. Alias routing is handled by that mailbox, not a second account.

Zoho Mail MCP does not download attachments. The REST helper requires separately user-authorized `ZohoMail.messages.READ,ZohoMail.accounts.READ`, with the `.com` region fixed. It independently checks the primary mailbox. Do not extract existing credentials from another deployment or service. No additional folder grant is needed when the report folder ID is confirmed by the owner.

Official sources: [Mail tools](https://www.zoho.com/mail/help/mcp/zoho-mail-mcp-tools.html), [server authorization](https://www.zoho.com/mail/help/mcp/mcp-server-configuration.html), [custom clients](https://help.zoho.com/portal/en/kb/mcp/implementation-guide/articles/zoho-mcp-implementation-guide), [attachment limitation](https://help.zoho.com/portal/en/kb/mcp/getting-started/articles/zoho-mcp-help-documentation), [official MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk).

## Development

The owner-run `owner-read-check.mjs` reuses the private stdin configuration and official SDK. It inventories tools, refuses writes/unknown tools, reports sanitized names and schema compatibility, and calls only runtime-discovered exact `getMailAccounts`/`listEmails` names when their schemas support the approved arguments. It matches the trusted owner primary address, keeps the discovered account ID only in memory, and requests at most one message. It returns only fixed status, owner-match boolean and zero/one count; no account IDs, addresses, subjects or message bodies. Unsupported names/schemas stop for review rather than guessed mappings. This is not a scheduler or a dot delivery service.

Six exact runtime-observed aliases are reviewed: `ZohoMail_getMailAccounts`, `ZohoMail_getAccountDetails`, `ZohoMail_listEmails`, `ZohoMail_SearchEmails`, `ZohoMail_getMessageContent`, and `ZohoMail_getMessageAttachmentInfo`. These map to existing allowed read semantics and retain their original runtime name on the wire. There is no general namespace stripping. Other prefixed names require explicit review; canonical and alias duplicates are refused. Canonical flat arguments map to the observed nested `path_variables` and `query_params` wrappers for account details, listing, search and content retrieval. Listing selects only the schema-documented `messageId` field; nested listing/search default to limit one and reject limits above 50. Writes, unknown arguments, unsupported schemas and owner mismatches stop before message retrieval.

Transport failures include only safe HTTP status, fixed request stage and MIME category, bounded Retry-After seconds, and an allowlisted error code. Network errors and timeouts remain distinct from HTTP rejection and 401/403 authorization challenges. An endpoint-pattern category contains no URL or key. The client requests Streamable HTTP and does not guess alternate routes or fall back to another transport. POST 405 is reported as rejection; GET 405 remains the supported indication that an optional server stream is unavailable. Provider error bodies are bounded to 8 KB and two seconds for code recognition, then discarded; no raw header, body or URL is reported.

Use the committed devcontainer. On Windows Podman use `devcontainer up --docker-path PATH_TO_PODMAN --workspace-folder PROJECT` then `devcontainer exec --docker-path PATH_TO_PODMAN --workspace-folder PROJECT npm test`. Dependencies use a dedicated Linux volume because chmod on Windows bind mounts can fail. The `:U` option applies only to that dependency volume; it does not change host source ownership. No engine socket is mounted into the development container. Use `npm ci --ignore-scripts`, not floating dependency updates.

Node 22 base image and GitHub Actions are pinned by verified digest/commit. Runtime dependencies: official client-only `@modelcontextprotocol/client` 2.3.0, strict XML parser `sax` 1.6.1 and ZIP parser `fflate` 0.8.3; transitive versions/integrity are in package-lock.json. CI runs tests in a container, then builds and publishes to GHCR using job-scoped `GITHUB_TOKEN` with only contents:read/packages:write. It does not publish pull-request images, use credentials as build inputs, or need a new PAT. A dependency audit with zero advisories is point-in-time evidence, not a guarantee.

## Private runtime configuration

All configuration is transient and separate from source/image. `cli.mjs` accepts exactly one private JSON envelope on stdin: `config` plus `request`. Never put the full MCP URL, token or REST credentials in shell arguments, logs, CI secrets, source or ordinary chat. Endpoint URLs can themselves contain API keys. No input-envelope file is necessary. MCP accepts only HTTPS `mcp.zoho.com`, exact endpoint path/query, verified certificates and no redirects; provider-host changes require review.

Policy fields are `accountId`, `primaryEmail`, `dmarcFolderId`, `dmarcDomain`; MCP credentials are `mcpUrl` and optional `mcpBearerToken`; REST fields live under `rest` as `ZOHO_CLIENT_ID`, `ZOHO_CLIENT_SECRET`, `ZOHO_REFRESH_TOKEN`. Folder/domain are pinned per invocation, cannot be overridden in tool arguments, and must be owner-confirmed. Keep all actual identifiers/configuration private.

Requests: `{ "action": "list_tools" }`, `{ "action": "call", "name": "listEmails", "arguments": { "accountId": "123", "limit": 1 } }`, or `{ "action": "dmarc", "messageId": "789" }`. Use discovered schemas before real calls. Examples are synthetic.

After explicit setup authorization, a user can run the container's ephemeral hidden prompt: `podman run --rm -it --read-only --cap-drop=ALL --security-opt=no-new-privileges --pids-limit=64 --memory=256m --entrypoint node IMAGE@sha256:DIGEST interactive.mjs`. Secret input is not echoed or saved. Do not run this in a recorded terminal. This prompt does not obtain new OAuth authorization or solve unattended credential persistence. Persistent encrypted storage/runtime identity and any OAuth consent must be approved separately; do not assume Podman's default secret store is encrypted. A reviewed same-user Windows protected-store bridge could supply private stdin after approval, without exposing secrets to the agent or environment.

For automation, a trusted launcher sends the private envelope to `podman run --rm -i --read-only --cap-drop=ALL --security-opt=no-new-privileges --pids-limit=64 --memory=256m IMAGE@sha256:DIGEST`. Safe bounded output is stdout, not a mailbox log. This repository does not configure that launcher, Windows tasks or conversation delivery.

## Limits and maintenance

DMARC: three attachments/message, 512,000 compressed bytes/attachment, 2,000,000 expanded bytes, one flat XML entry/ZIP (stored/deflate), 1,000 records, 30,000 nodes, depth 20, field text 1,024 characters. Strict UTF-8, DTD/custom entities/processing instructions rejected, no external entity fetching. Nested archives/traversal, malformed required fields and wrong domains fail. Full XSD and ZIP CRC validation are not provided. Results are untrusted descriptive report data, not proof of authenticity or attack. Identical report IDs deduplicate within one message; changed aggregate content with the same ID is flagged. No cross-message state or retention. Larger analytics/retention needs favor a maintained DMARC analyzer after separately approving data disclosure.

HTTP/MCP responses cap at 1.5 MB before parsing; projected MCP output caps at 100 KB, 50 items per array and 50 KB per string. REST token refresh and Mail calls time out; mailbox/account responses are not cached. Only access tokens are reused in process memory, with single-flight refresh, expiry margin and failure cooldown. A one-shot container loses that cache on exit, so frequent separate REST runs can hit Zoho's refresh throttle. Do not retry rapidly: after documented throttle wait ten minutes before one probe. Never create a persistent plaintext token cache as a workaround.

Preserve the last verified image digest; pull a proposed digest, run offline/small live checks, then update the approved launcher. Rollback selects the previous digest and does not restore old grants or secrets. Revoke/rotate MCP endpoint keys and Zoho grants in the user's official consoles; update the separately approved credential bridge securely. Confirm outstanding access-token validity windows with the provider. Review pinned dependencies, base image and CI actions periodically, test changes in the devcontainer, inspect publication contents/history and publish a new immutable image. No automatic updater is configured.

Keep existing deployments/schedules until live Mail, DMARC and the outbound runner/result-delivery channel pass. Cloud schedules cannot directly invoke a private offline host. No new public inbound endpoint should be introduced to bridge that gap.