@skavinski/openclaw-zoho
v0.1.7
Published
Third-party OpenClaw channel plugin for Zoho Mail and Calendar.
Downloads
958
Maintainers
Readme
@skavinski/openclaw-zoho
Third-party OpenClaw channel plugin exposing Zoho Mail and Zoho Calendar under a single OAuth account.
Status
Unofficial, community-maintained plugin. Not affiliated with, endorsed, or certified by Zoho Corporation. MIT licensed. Provided AS-IS, without warranty of any kind (see LICENSE for full terms). APIs and configuration shape may change before 1.0.0.
Install
npm install -g @skavinski/openclaw-zohoOpenClaw discovers this plugin through the shipped openclaw.plugin.json manifest and the openclaw section of package.json, which together declare:
- the channel id (
zoho), - the compiled entry point (
./dist/index.js), and - the setup-wizard entry point (
./dist/setup-entry.js).
After install, the host registers the plugin through defineChannelPluginEntry from openclaw/plugin-sdk/core (see src/index.ts:1). This is a third-party entry point, not the in-repo defineBundledChannelEntry used for bundled channels.
BYO Zoho OAuth credentials
This plugin operates no hosted service. Every user registers their own Zoho OAuth application and supplies the credentials locally.
- Sign in at https://api-console.zoho.com/ with the account that will own the integration. The API Console region (US, EU, IN, AU, JP, CA) must match the region where the Zoho account is hosted. Six regions are supported — see
src/config/regions.ts:7. - Create a Server-based Application:
- Client Name: free-form.
- Homepage URL:
http://localhostis fine. - Authorized Redirect URI: leave empty if you plan to use the loopback flow (the plugin chooses the port at pairing time). If you want to pre-register a URI for the paste-back flow, register the exact URL you will paste.
- After creation, capture Client ID (looks like
1000.XXXXXXXXXXXX) and Client Secret. The plugin never stores the secret in plain config; it reads it through the OpenClaw secret adapter assecret://zoho/<account>/client-secret.
Region endpoints
The plugin uses the OAuth, Mail, and Calendar hostnames that match the configured dataCenter. The mapping is defined in src/config/regions.ts:11:
| dataCenter | Accounts host | Mail host | Calendar host |
|------------|----------------------------|------------------------|----------------------------|
| us | accounts.zoho.com | mail.zoho.com | calendar.zoho.com |
| eu | accounts.zoho.eu | mail.zoho.eu | calendar.zoho.eu |
| in | accounts.zoho.in | mail.zoho.in | calendar.zoho.in |
| au | accounts.zoho.com.au | mail.zoho.com.au | calendar.zoho.com.au |
| jp | accounts.zoho.jp | mail.zoho.jp | calendar.zoho.jp |
| ca | accounts.zohocloud.ca | mail.zohocloud.ca | calendar.zohocloud.ca |
Pairing a mismatched region surfaces as RegionMismatch. SA and UK data centers are not currently supported.
Redirect URI
The loopback mode uses http://127.0.0.1:<ephemeral>/oauth/zoho/callback. The port is selected at pairing time (src/auth/loopback-server.ts:99, listen({ host, port: 0 })) and the listener is single-shot with a 5-minute hard timeout.
Pairing flow
Two pairing paths are shipped:
In-chat pairing via zoho.connect (primary, mobile-compatible)
The zoho.connect agentTool walks the user through the full BYO-OAuth dance over the paired chat channel (e.g., Telegram). Say "connect my Zoho" — the LLM invokes the tool, and the plugin asks a fixed sequence of questions through the host's interactive reply surface:
- What label should I use? (e.g.
work,personal) - Paste your Zoho Client ID. (Format:
1000.XXXXXXXXXXXX.) - Now paste your Client Secret. It's encrypted at rest and redacted in all logs.
- Which Zoho data center? (
US,EU,IN,AU,JP, orCA.) - Authorize at:
https://accounts.zoho.<tld>/oauth/v2/auth?...— after authorizing, Zoho redirects tohttps://localhost/zoho-callback(which 404s). Paste the full URL from your browser bar, or just thecode=...segment.
Reply cancel at any step to abort cleanly. The plugin validates each step (regex for Client ID, region allowlist, non-empty secret, parseable redirect paste) and retries up to 3 times per step before aborting. Timeout per prompt defaults to 10 minutes.
PKCE S256 is mandatory. State tokens are verified in constant time (src/auth/state.ts:36). On success the refresh token, access token, client secret, and scope-separator preference land in the secret adapter; zoho.connect returns a confirmation message and the regular mail/calendar tools become usable.
Process restart aborts any in-progress pairing. State lives only in memory (src/util/pairing-state.ts). If the host restarts mid-pairing, ask the user to start over with "connect my Zoho".
Example transcript (abridged):
user > connect my Zoho
bot > What label should I use? (e.g. 'work', 'personal')
user > personal
bot > Paste your Zoho Client ID...
user > 1000.ABCDEFGHIJ
bot > Now paste your Client Secret...
user > [redacted]
bot > Which Zoho data center? Reply: US, EU, IN, AU, JP, or CA.
user > US
bot > Authorize at: https://accounts.zoho.com/oauth/v2/auth?client_id=...
After authorizing, paste the full URL from your browser bar.
user > https://localhost/zoho-callback?code=...&state=...
bot > Connected Zoho account 'personal' in region 'US'. You can now
ask me to check mail or calendar.Desktop loopback pairing (legacy, still shipped)
For desktop use the plugin also retains the original loopback flow:
- The plugin generates a PKCE S256 verifier/challenge pair (mandatory, no fallback to plain).
- It starts a single-shot loopback server on
127.0.0.1at an ephemeral port (src/auth/loopback-server.ts:34) and opens the Zoho authorization URL in the user's browser. - Zoho redirects back to the loopback with
codeandstate. The plugin verifies state, exchanges the code for access + refresh tokens (src/auth/oauth-client.ts:73), and stores them via the secure token store. - If the loopback path is unavailable (e.g., firewall, port blocked, headless host), pairing falls back to manual paste-back of the redirect URL.
- The listener closes after 5 minutes or after a single successful callback, whichever comes first.
RT-003 scope-format auto-detection (in-chat pairing only). Zoho's docs have been inconsistent about whether OAuth scopes are comma-separated or space-separated. zoho.connect auto-detects: if the first token exchange fails with a scope-related error, the plugin rebuilds the authorize URL with the flipped separator and re-prompts the user for a fresh code. Whichever separator succeeds is persisted to the secret adapter alongside the account's other credentials.
Scopes
The plugin does not hard-code Zoho scope literals — the Zod schema (src/config/schema.ts:29) accepts a scopes.mail and scopes.calendar array supplied by config. The recommended minimal set is:
| Scope | Used for |
|---------------------------------------|------------------------------------------------------|
| ZohoMail.messages.READ | zoho.mail.list, zoho.mail.get |
| ZohoMail.messages.CREATE | zoho.mail.send |
| ZohoMail.accounts.READ | Account discovery |
| ZohoCalendar.event.READ | zoho.calendar.event.list, zoho.calendar.event.get |
| ZohoCalendar.event.CREATE | zoho.calendar.event.create |
| ZohoCalendar.event.UPDATE | zoho.calendar.event.update |
| ZohoCalendar.event.DELETE | zoho.calendar.event.delete |
Do not use the .ALL variants — they grant far more than this plugin needs.
Rate limits
| Limit | Value | Scope | |------------------------|-------------|------------------------------| | Mail send (per minute) | 10 | Per account | | Mail send (per day) | 100 | Per account | | Overall API (per day) | 180 | Per account (configurable) |
The overall budget defaults to 180/day because Zoho's free-tier ceiling is 200/day for most plans; leaving 20/day of headroom avoids tripping the server-side quota during normal use. When a limit is hit, the plugin returns RateLimited with a retryAfterMs hint. Read and write operations share the overall daily bucket; mail send additionally consumes from the per-minute and per-day mail buckets.
See src/util/rate-limit.ts:58 for the bucket definitions.
Known limitation (RT-007 / Z-18): rate-limit state is process-local. Token buckets live in the
ZohoRuntimeBindingsfor the lifetime of the current process. A host restart, orstopAccount→startAccounton this plugin, creates fresh buckets and effectively resets the budgets. This means a misbehaving agent can reset its own quota by cycling the plugin; the 200/day Zoho ceiling still applies at the upstream API. Persisting state to disk is planned for v0.2 (tracked as Z-18).
Confirmation UX
When requirePerSendConfirmation is true (the default), every write operation — zoho.mail.send, zoho.mail.delete, zoho.calendar.event.create, zoho.calendar.event.update, zoho.calendar.event.delete — is gated behind an explicit confirmation prompt delivered through the paired channel (e.g., Telegram). The user is shown a short summary of the action and must reply y or yes for the plugin to call Zoho. Any other response, or no response within the dispatcher's timeout, aborts the action.
The confirmation contract is defined in src/util/confirm.ts:28. Read operations (list, get) do not request confirmation.
⚠️ Host integration gotcha — three overrides are required
openclaw/plugin-sdk does not currently export a stable type for ctx.channelRuntime.reply (tracked upstream as OC-ZH-013) or a gateway-bound caller principal (OC-ZH-012). Until those land, the plugin ships safe-deny defaults for three injection points and the host MUST supply all three when constructing the gateway adapter:
confirmDispatcher— powers per-send write confirmation. Default: safe-deny. Without it, every write tool is rejected withForbidden [CONFIRMATION_DECLINED], andstartAccountthrows up-front (RT-009) whenrequirePerSendConfirmation=true.interactiveDispatcher— round-trip prompt surface used by thezoho.connectin-chat pairing tool. Default: safe-deny (cancels pairing immediately). Without it,zoho.connectreturns an error explaining the host hasn't wired the surface.principalProvider— resolves the caller principal at tool-execute time (RT-005). Default: undefined, which makes authz fail closed (Forbidden [MISSING_PRINCIPAL]) wheneverallowedSendersis non-empty.
Minimal host wiring:
import { buildZohoGatewayAdapter } from "@skavinski/openclaw-zoho";
const zohoGatewayAdapter = buildZohoGatewayAdapter({
secretAdapter: mySecretAdapter,
// 1. Confirm writes through the paired channel.
confirmDispatcher: async (question) => {
const accepted = await askPairedUser(question.summary); // y/yes/no
return { accepted };
},
// 2. Round-trip text prompts for in-chat pairing.
interactiveDispatcher: async ({ prompt, timeoutMs }) => {
const reply = await awaitPairedReply(prompt, { timeoutMs });
if (reply.kind === "text") return { kind: "reply", text: reply.text };
if (reply.kind === "cancel") return { kind: "cancelled" };
return { kind: "timeout" };
},
// 3. Resolve caller principal for the current tool call.
principalProvider: () => currentContext.getPairedSenderPrincipal()
});Without these injections, read tools (list, get) still work if allowedSenders is empty; write tools are denied; zoho.connect refuses to run.
Security notes
- Tokens. Client secrets and refresh tokens are stored through the OpenClaw secret adapter (
secret://zoho/<account>/<key>). Access tokens live in the per-accountTokenStoreand are refreshed on demand; they are never written to disk in clear form. - Log redaction. No log level emits partial reveals of OAuth tokens.
src/util/redact.ts:42full-masks access and refresh tokens uniformly across DEBUG/INFO/WARN/ERROR (RT-001 fix). Client IDs mask to1000.****. Email addresses truncate at DEBUG (e***@d***.tld) and drop to[EMAIL]at INFO and above. Mail bodies and subjects are stripped by the logger before formatting (FORBIDDEN_FIELDSinsrc/util/redact.ts:89) and never reach disk. - ACL (B4). Every outbound tool invocation passes through the
allowedSendersauthz gate. The caller principal is plumbed via the host-suppliedprincipalProvider(see the advisory ACL callout below) — the LLM cannot forge a principal because the principal is not part of any tool schema. Authz fails closed (FORBIDDEN / MISSING_PRINCIPAL) when no provider is wired andallowedSendersis non-empty. Seesrc/util/authz.ts:28and the RT-005 fix insrc/tools/mail-tools.ts. - Untrusted content sentinels (RT-008). Every string field that originates outside the paired account (mail subject, body, addresses; event title, description, location, attendees) is wrapped in
<untrusted-content source="zoho-mail|zoho-calendar">…</untrusted-content>before it is returned by a tool. The wrapper escapes<and>so an attacker cannot close the sentinel and escape the envelope. Defense-in-depth against prompt injection through mail/calendar content — seesrc/util/untrusted.ts.
⚠️ Security: allowedSenders is advisory until OC-ZH-012 (RT-005)
The allowedSenders ACL is advisory defense-in-depth, not a gateway-enforced authz bind. The upstream openclaw/plugin-sdk does not yet attach a tamper-proof caller principal to every outbound tool invocation (tracked upstream as OC-ZH-012 — see docs/upstream-issues/02-gateway-caller-principal.md). Until that lands:
- The plugin reads the caller principal via an optional
principalProvideroverride onbuildZohoGatewayAdapter({ principalProvider }). The provider is the ONLY surface the ACL consults —_callerPrincipalhas been removed from every tool schema (RT-005 fix) so the LLM cannot forge it via a normal tool-call payload. - When
allowedSendersis non-empty and noprincipalProvideris wired, tool calls fail closed (Forbidden [MISSING_PRINCIPAL]). The security adapter surfaces a[CRITICAL]warning and azoho.acl.advisoryaudit finding so operators notice. - A compromised agent or a prompt-injection payload that breaks out of the agent's own boundary (e.g., by tricking it into changing tool-call arguments) cannot bypass the ACL, because the principal is never taken from a model-supplied field.
- A compromise of the host process itself, or a flawed host
principalProviderimplementation, can still defeat the ACL. Treat this layer accordingly.
Until OC-ZH-012 is resolved upstream, hosts SHOULD wire a principalProvider that returns the paired-sender id for the current conversation:
import { buildZohoGatewayAdapter } from "@skavinski/openclaw-zoho";
const zohoGatewayAdapter = buildZohoGatewayAdapter({
secretAdapter: mySecretAdapter,
confirmDispatcher: myConfirmDispatcher,
// Return the paired-sender id for the current tool call, or undefined
// if it can't be determined (the plugin will fail closed in that case).
principalProvider: () => currentContext.getPairedSenderPrincipal()
});npm audit noise (OC-ZH-014)
A fresh install currently surfaces roughly 17 advisories from npm audit. All of them come from the openclaw peer dependency's own transitive tree (Discord, WhatsApp, and Lark SDKs pulled in by the host), not from this plugin's own dependencies.
This plugin's direct runtime dependencies are only @sinclair/typebox and zod (see package.json:52). The files whitelist in package.json:16 ships only dist/, openclaw.plugin.json, LICENSE, and README.md — none of the vulnerable transitive surface is redistributed by this package. The advisories reflect host-side risk owned by openclaw, and are tracked upstream as OC-ZH-014.
agentTools reference
All tool parameter schemas are defined with TypeBox in src/tools/mail-tools.ts, src/tools/calendar-tools.ts, and src/tools/pairing-tool.ts. Every tool accepts an optional accountId when multiple Zoho accounts are registered. The caller principal is not a tool-schema field (RT-005): the LLM cannot set it. Principal is read from the host-supplied principalProvider at execute time — see the advisory-ACL callout above.
Pairing
| Tool | Purpose | Inputs | Output |
|-----------------|---------------------------------------------|---------------------------------------------------------------------------------------------|------------------------------------------------------|
| zoho.connect | Walk the user through in-chat Zoho pairing. | accountLabel? (free-form label, URL-safe). Secrets are captured via the paired channel, never via LLM-visible params. | { ok, accountLabel, region, scopeSeparator } |
zoho.connect requires interactiveDispatcher, principalProvider, and secretAdapter on the gateway adapter. It fails closed if any is missing.
| Tool | Purpose | Inputs | Output |
|---------------------|-----------------------------------------|--------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|
| zoho.mail.list | List recent inbox messages. | limit? (1–100, default 20), accountId? | { ok, count, items[] } |
| zoho.mail.get | Fetch one message by id. | messageId (required), accountId? | { ok, message } |
| zoho.mail.send | Send a message. Requires confirmation. | to[] (email), cc?[], bcc?[], subject, body, format? (plaintext|html), from?, accountId? | { ok, messageId } |
| zoho.mail.delete | Delete a message (moves to Trash, recoverable). Requires confirmation. | messageId (required), accountId? | { ok, messageId, deleted } |
zoho.mail.send requires fromAddress either in config (account.fromAddress) or passed via the from argument — otherwise the call fails with UnsupportedOperation [NO_FROM_ADDRESS].
zoho.mail.delete calls Zoho's DELETE /api/accounts/{accountId}/messages/{messageId} which moves the message to Trash (Zoho retains it for ~30 days and the user can restore it from the web UI). This is a soft delete, not a permanent purge.
Calendar
| Tool | Purpose | Inputs | Output |
|-----------------------------------|---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------|
| zoho.calendar.event.list | List events in a date range. | start, end, accountId? | { ok, count, items[] } |
| zoho.calendar.event.get | Fetch one event by id. | eventId, accountId? | { ok, event } |
| zoho.calendar.event.create | Create an event. Requires confirmation. | title, startTime, endTime, description?, location?, attendees?[], allDay?, accountId? | { ok, eventId } |
| zoho.calendar.event.update | Patch an event. Requires confirmation. | eventId, plus any subset of title, startTime, endTime, description, location, attendees, allDay, accountId? | { ok, eventId } |
| zoho.calendar.event.delete | Delete an event. Requires confirmation. | eventId, accountId? | { ok, eventId } |
Tool results follow the pi-agent-core { content, details } convention. Errors surface as { isError: true, content, details: { ok: false, code, retryable, userMessage, requestId } } with the ZohoError taxonomy from src/zoho/errors.ts.
Note on sendText
The channel's outbound adapter (src/adapters/outbound.ts:20) always throws UnsupportedOperation [SEND_TEXT_NOT_SUPPORTED]. Mail delivery exclusively flows through the zoho.mail.send agent tool so that the authz, rate-limit, and confirmation gates cannot be bypassed.
Troubleshooting
- WSL portproxy drift. The WSL VM's IP changes on each Windows reboot. A
netsh interface portproxyrule pinned to yesterday's WSL IP will silently stop forwarding, and OAuth callbacks will hang until the 5-minute loopback timeout fires. Refresh the portproxy rule to the current WSL IP, or pair directly on the host side. - OAuth loopback port blocked. Host firewalls sometimes refuse to bind ephemeral ports on
127.0.0.1— or a corporate proxy intercepts the browser redirect. Symptom: the browser tab shows the Zoho success page but the CLI never receives the callback. Workaround: use the manual paste-back fallback during pairing. - Pairing timeout. The loopback listener closes after exactly 5 minutes (
src/auth/loopback-server.ts:29). If you were away from the browser, restart the pairing flow. npm auditadvisories on install. Expected — see the audit noise section above. None originate in this plugin's own dependency closure.Forbidden [CONFIRMATION_DECLINED]on every write. The host hasn't injected aconfirmDispatcher. See the host integration gotcha section.zoho.connectimmediately reports "interactiveDispatcher not wired". The host hasn't injected aninteractiveDispatcheroverride — in-chat pairing is impossible without it. See the host integration gotcha section.
Contributing
Issues and patches welcome. Bug tracker URL will be added here once public. The unit-test suite covers the security-critical modules — please run npm run check before submitting a patch.
License
MIT. See LICENSE.
Not affiliated with Zoho. Zoho, Zoho Mail, and Zoho Calendar are trademarks of Zoho Corporation Pvt. Ltd. This project is not endorsed, sponsored, or certified by Zoho. Bring your own credentials — no credentials or user data are transmitted to the plugin author.
